feat(coding-agent): redesigned agent hub layout for task clarity

- Redesigned agent hub entries as two-line cards, separating identity and status from task descriptions.
- Added explicit model and thinking level badges to agent status information.
- Updated agent hub rendering to support multi-line entries and adaptive vertical scrolling based on row height.
- Simplified status display by using glyphs instead of redundant status labels.
- Adjusted rendering logic to prioritize displaying agent task summaries on an independent indented line.
This commit is contained in:
can1357
2026-07-13 03:03:26 +02:00
parent 14aa1e2065
commit 42d81f189f
3 changed files with 110 additions and 40 deletions
+1
View File
@@ -6,6 +6,7 @@
- Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays
- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles
- Redesigned Agent Hub entries as two-line cards: identity (status glyph, name, agent type, parent when nested) on the left, active model + reasoning level and age right-aligned, with the task description on its own line; dropped the redundant `sub · of Main` noise
### Changed
@@ -15,8 +15,8 @@
*/
import * as fs from "node:fs";
import * as path from "node:path";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui";
import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor";
import type { KeyId } from "../../config/keybindings";
@@ -25,6 +25,7 @@ import { IrcBus } from "../../irc/bus";
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
import { USER_INTERRUPT_LABEL } from "../../session/messages";
import { parseThinkingLevel } from "../../thinking";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
import { theme } from "../theme/theme";
@@ -55,20 +56,50 @@ function clampHubLine(line: string, width: number): string {
const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
/** Glyph + status word, colored per theme status conventions. */
function statusBadge(status: AgentStatus): string {
/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */
function statusGlyph(status: AgentStatus): string {
switch (status) {
case "running":
return theme.fg("accent", `${theme.status.running} running`);
return theme.fg("accent", theme.status.running);
case "idle":
return theme.fg("success", `${theme.status.enabled} idle`);
return theme.fg("success", theme.status.enabled);
case "parked":
return theme.fg("muted", `${theme.status.shadowed} parked`);
return theme.fg("muted", theme.status.shadowed);
case "aborted":
return theme.fg("error", `${theme.status.aborted} aborted`);
return theme.fg("error", theme.status.aborted);
}
}
/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
const model = theme.fg("muted", replaceTabs(modelId));
if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
}
/**
* Active model + reasoning level for a hub row: live session state when the
* agent is attached, else the executor-reported `resolvedModel` selector
* (`provider/id`, optionally `:<level>`). Undefined when neither is known
* (e.g. a parked historical agent restored from disk).
*/
function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
const model = ref.session?.model;
if (model) {
const level = model.thinking ? ref.session?.thinkingLevel : undefined;
return formatModelBadge(model.id, level);
}
const resolved = observed?.progress?.resolvedModel;
if (!resolved) return undefined;
// Model ids may themselves contain colons (`qwen3:14b`), so only treat the
// suffix as a thinking level when it parses as one.
const colon = resolved.lastIndexOf(":");
const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
}
async function registerPersistedSubagents(
registry: AgentRegistry,
sessionFile: string | null | undefined,
@@ -427,17 +458,31 @@ export class AgentHubOverlayComponent extends Container {
} else {
const termHeight = process.stdout.rows || 40;
// Chrome: 2 borders + title + notice? + blank + hints + border
const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0));
let start = 0;
if (this.#rows.length > maxVisible) {
start = Math.min(
Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)),
this.#rows.length - maxVisible,
);
const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width));
// Entries are 1-2 lines tall; grow a window around the selection until
// the line budget is spent, so the selected entry stays centered.
let start = this.#selectedRow;
let end = this.#selectedRow + 1;
let used = entries[start]?.length ?? 0;
for (let grew = true; grew; ) {
grew = false;
if (end < entries.length && used + entries[end].length <= budget) {
used += entries[end].length;
end++;
grew = true;
}
if (start > 0 && used + entries[start - 1].length <= budget) {
start--;
used += entries[start].length;
grew = true;
}
}
if (start > 0) {
lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
}
const end = Math.min(start + maxVisible, this.#rows.length);
for (let i = start; i < end; i++) {
lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width));
lines.push(...entries[i]);
}
if (end < this.#rows.length) {
lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
@@ -466,26 +511,51 @@ export class AgentHubOverlayComponent extends Container {
return parts.join(theme.sep.dot);
}
#renderRow(ref: AgentRef, selected: boolean, width: number): string {
/**
* One agent entry, 1-2 lines:
* `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity
* left, metadata right-aligned (inlined when the terminal is too narrow) —
* plus an indented dim task line when the agent's work is known.
*/
#renderEntry(ref: AgentRef, selected: boolean, width: number): string[] {
const max = Math.max(1, width - 2);
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
parts.push(theme.fg("dim", replaceTabs(ref.displayName)));
parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
if (ref.kind === "advisor") {
parts.push(theme.fg("warning", "read-only"));
const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`];
if (ref.displayName && ref.displayName !== ref.id) {
fields.push(theme.fg("dim", replaceTabs(ref.displayName)));
}
const observed = this.#observableFor(ref.id);
const task = observed?.description ?? observed?.progress?.task;
if (task) {
parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE)));
if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`));
}
if (ref.kind === "advisor") {
fields.push(theme.fg("warning", "read-only"));
}
const unread = this.#irc.unreadCount(ref.id);
if (unread > 0) {
parts.push(theme.fg("warning", `⧉ ${unread}`));
fields.push(theme.fg("warning", `⧉ ${unread}`));
}
parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
const rawLine = ` ${cursor} ${parts.join(theme.sep.dot)}`;
return truncateToWidth(rawLine.replace(/[\r\n]+/g, " "), Math.max(1, width - 1));
const left = ` ${fields.join(" ")}`;
const observed = this.#observableFor(ref.id);
const meta: string[] = [];
const badge = modelBadge(ref, observed);
if (badge) meta.push(badge);
meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
const right = meta.join(theme.sep.dot);
const leftWidth = visibleWidth(left);
const rightWidth = visibleWidth(right);
const line =
leftWidth + 2 + rightWidth <= max
? left + padding(max - leftWidth - rightWidth) + right
: truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max);
const entry = [line];
const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
if (task) {
entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`);
}
return entry;
}
#handleTableInput(keyData: string): void {
@@ -53,15 +53,14 @@ function makeHub(agents: AgentRegistry) {
}
function renderedAgentIds(hub: AgentHubOverlayComponent): string[] {
return hub
.render(120)
.map(line => Bun.stripANSI(line))
.map(line => line.split(" · "))
.filter(
parts =>
parts.length >= 4 && ["running", "idle", "parked", "aborted"].some(status => parts[0].endsWith(status)),
)
.map(parts => parts[1]!);
// Entry first lines are ` <cursor> <status-glyph> <id> …`; task lines are
// indented deeper and chrome lines never carry the cursor slot.
const ids: string[] = [];
for (const raw of hub.render(120)) {
const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw));
if (match) ids.push(match[2]!);
}
return ids;
}
describe("Agent hub row ordering", () => {