From a0bd140eaaba69d360d8fd7946274bbe6837d6da Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 16:04:40 +0800 Subject: [PATCH] feat(ui): dock pickers above the input and unify subagent status chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dock the /subagents and /ps pickers just above the editor (bottom-center overlay, content-sized with a 10-row scroll cap) instead of covering the conversation; takeover and detail views stay fullscreen. - Collapse the subagent strip's marker+status-square pair into one status glyph (running/done/failed), show worded counts (1 running / 1 done / 1 failed) instead of 0/1 agents, and trim context utilization to a bare percent; the per-subagent model stays in the takeover view since the footer already shows a model. - Drop the redundant footer status line in the TUI: the strip already reports the same activity with the manage affordance. - Replace the heavy ■/● markers in the settled summary, result, and finished-notification renderers with the ✓/!/x status vocabulary. --- extensions/background-terminals/src/ui/ps.ts | 34 +++++++++++------- extensions/shared/below-editor-navigation.ts | 4 +-- extensions/subagents/index.ts | 16 +++++---- extensions/subagents/navigation.test.ts | 10 +++--- extensions/subagents/navigation.ts | 38 +++++++++++++------- extensions/subagents/src/ui/takeover.ts | 20 ++++++++--- extensions/subagents/src/ui/wait-result.ts | 2 +- extensions/workflows/navigation.ts | 15 +++++--- 8 files changed, 91 insertions(+), 48 deletions(-) diff --git a/extensions/background-terminals/src/ui/ps.ts b/extensions/background-terminals/src/ui/ps.ts index bcd11e86..0d10761e 100644 --- a/extensions/background-terminals/src/ui/ps.ts +++ b/extensions/background-terminals/src/ui/ps.ts @@ -1,10 +1,10 @@ /** - * /ps UI — two-stage full-screen overlay over the synchronous - * TerminalReadModel: - * - TerminalDashboard: list of all tracked terminals (select, kill, open). - * - TerminalDetailView: read-only inspector for one terminal — metadata, - * stdout/stderr toggle, scrolling, live tail. No input surface: background - * terminals have no stdin by design. + * /ps UI — two-stage inspector over the synchronous TerminalReadModel: + * - TerminalDashboard: compact picker docked above the input, listing all + * tracked terminals (select, kill, open). + * - TerminalDetailView: full-screen read-only inspector for one terminal — + * metadata, stdout/stderr toggle, scrolling, live tail. No input surface: + * background terminals have no stdin by design. */ import type { @@ -92,7 +92,14 @@ export async function openTerminalPicker( new TerminalDashboard(tui, theme, keybindings, view, selection, done), { overlay: true, - overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" }, + // Dock the picker just above the editor (editor + strip + footer ≈ 6 + // rows) like a command palette, instead of blanking the conversation. + overlayOptions: { + anchor: "bottom-center", + width: "100%", + maxHeight: "60%", + margin: { bottom: 6 }, + }, }, ); @@ -111,7 +118,10 @@ export async function openTerminalPicker( } } -// --- Dashboard (fullscreen overlay) ---------------------------------------------- +// --- Dashboard (picker docked above the input) --------------------------------- + +/** A picker is a glance, not a workspace: cap the list window and scroll. */ +const MAX_PICKER_ROWS = 10; export interface DashboardSelection { id?: string; @@ -228,11 +238,9 @@ class TerminalDashboard implements Component { const terminals = this.terminals(); reconcileDashboardSelection(this.selection, terminals); - const rows = this.tui.terminal.rows || 30; - // Render exactly terminal rows - 1 so the overlay covers the header, - // chat, editor, and extra footer lines while leaving pi's final footer - // row visible. - const bodyHeight = Math.max(6, rows - 5); + // Size the panel to its content (bounded, scrolling past the cap) so the + // docked picker never covers more conversation than the list needs. + const bodyHeight = Math.min(Math.max(terminals.length, 1), MAX_PICKER_ROWS); const running = terminals.filter((s) => s.status === "running").length; const keys = (binding: Parameters[0]) => configuredKeys(this.keybindings, binding); diff --git a/extensions/shared/below-editor-navigation.ts b/extensions/shared/below-editor-navigation.ts index ad105f2a..6d27bc4a 100644 --- a/extensions/shared/below-editor-navigation.ts +++ b/extensions/shared/below-editor-navigation.ts @@ -334,8 +334,8 @@ export class BelowEditorNavigationEditor implements EditorComponent, Focusable { * a hint that recedes furthest. * * The whole tail used to be painted in the status colour, which made a routine - * "0/1 agents · 9m51s · ↓ to manage" shout as loudly as a failure. The status - * already has a coloured square on the left edge, so the tail only borrows that + * "1 running · 9m51s · ↓ to manage" shout as loudly as a failure. The status + * already has a coloured glyph on the left edge, so the tail only borrows that * colour for the one count that carries the outcome — and only once the run has * settled, where the colour means something. */ diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index f0378a54..e3d0104d 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -277,7 +277,7 @@ function renderSubagentResult( } const failed = details.status === "error"; - const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■"); + const icon = failed ? theme.fg("error", "x") : theme.fg("success", "✓"); const header = `${icon} ` + theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) + @@ -388,9 +388,12 @@ export default function (pi: ExtensionAPI) { manager.view.list(), settledAcknowledgedAt, ); + // In the TUI the below-editor strip already reports the same activity and + // carries the manage affordance, so a footer status line would repeat it. + const tui = sessionContext?.mode === "tui"; ui.setStatus( "subagents", - hasActivity(counts) + !tui && hasActivity(counts) ? formatActivityStatus(ui.theme, "subagents", counts) : undefined, ); @@ -1146,11 +1149,12 @@ export default function (pi: ExtensionAPI) { (entry, _options, theme) => { const data = entry.data; const failed = data?.status === "error"; + const icon = failed ? theme.fg("error", "x") : theme.fg("success", "✓"); return new Text( - `${theme.fg(failed ? "error" : "success", "\u25cf")} ` + + `${icon} ${theme.fg("accent", data?.title ?? "?")}` + theme.fg( - "muted", - `Agent "${data?.title ?? "?"}" ${failed ? "failed" : "finished"} \u00b7 ${data?.elapsed ?? "?"}`, + "dim", + ` ${failed ? "failed" : "finished"} · ${data?.elapsed ?? "?"}`, ), 1, 0, @@ -1163,7 +1167,7 @@ export default function (pi: ExtensionAPI) { (entry, { expanded }, theme) => { const data = entry.data; const failed = data?.status === "error"; - const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■"); + const icon = failed ? theme.fg("error", "x") : theme.fg("success", "✓"); const header = `${icon} ` + theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) + diff --git a/extensions/subagents/navigation.test.ts b/extensions/subagents/navigation.test.ts index 3e7ea81a..dce0b42c 100644 --- a/extensions/subagents/navigation.test.ts +++ b/extensions/subagents/navigation.test.ts @@ -140,14 +140,14 @@ test("the metrics tail stays quiet while a run is healthy", () => { } }; - // A routine run borrows no status colour in its tail: the coloured square on + // A routine run borrows no status colour in its tail: the coloured glyph on // the left already carries the state, and hints recede furthest of all. const running = render("running"); - assert.match(running, /0\/1 agents<\/muted>/); + assert.match(running, /1 running<\/muted>/); assert.match(running, /↓ to manage<\/dim>/); - assert.doesNotMatch(running, /0\/1 agents/); + assert.doesNotMatch(running, /1 running/); // Once settled, the one count that carries the outcome takes the colour. - assert.match(render("error"), /1\/1 agents<\/error>/); - assert.match(render("done"), /1\/1 agents<\/success>/); + assert.match(render("error"), /1 failed<\/error>/); + assert.match(render("done"), /1 done<\/success>/); }); diff --git a/extensions/subagents/navigation.ts b/extensions/subagents/navigation.ts index 585cb905..0b74bad2 100644 --- a/extensions/subagents/navigation.ts +++ b/extensions/subagents/navigation.ts @@ -11,7 +11,7 @@ import { } from "../shared/activity-status.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts"; -import { formatContextUtilization } from "./src/format.ts"; +import { contextPercent } from "./src/format.ts"; export interface SubagentStripEntry { snapshot: SubagentSnapshot; @@ -59,8 +59,11 @@ function statusColor(status: SubagentSnapshot["status"]) { return "error" as const; } -function statusSquare(snapshot: SubagentSnapshot, theme: Theme) { - return theme.fg(statusColor(snapshot.status), "■"); +/** One status glyph per run state; doubles as the focus marker when selected. */ +function statusGlyph(snapshot: SubagentSnapshot, theme: Theme) { + if (snapshot.status === "running") return theme.fg("warning", "●"); + if (snapshot.status === "done") return theme.fg("success", "✓"); + return theme.fg("error", "x"); } /** One-line subagent manager entry with the same affordance as Workflow. */ @@ -95,25 +98,34 @@ export class SubagentStripWidget { const entry = this.getEntry(); if (!entry || width <= 0) return []; const { snapshot, counts } = entry; - const marker = this.strip.focused + const glyph = this.strip.focused ? this.theme.fg("accent", "❯") - : this.theme.fg("dim", "○"); + : statusGlyph(snapshot, this.theme); const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id); const title = this.strip.focused ? this.theme.bold(this.theme.fg("accent", titleText)) : this.theme.fg("text", titleText); - const model = snapshot.meta.modelLabel - ? cleanLine(snapshot.meta.modelLabel) - : undefined; - const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`; - const settled = counts.done + counts.failed; - const total = counts.running + settled; + // The footer already shows the session model; the takeover view keeps the + // per-subagent model, so the one-line strip stays title-only. + const left = ` ${glyph} ${title}`; + // Worded counts read at a glance; the selected run's own state comes + // first so the emphasis colour always lands on the matching count. + const donePart = counts.done > 0 ? `${counts.done} done` : undefined; + const failedPart = + counts.failed > 0 ? `${counts.failed} failed` : undefined; + const activity = + counts.running > 0 + ? [`${counts.running} running`] + : snapshot.status === "error" + ? [failedPart, donePart] + : [donePart, failedPart]; + const percent = contextPercent(snapshot.usage); const right = renderNavigationMetrics( this.theme, [ - `${settled}/${total} agents`, + ...activity, formatElapsed(snapshot), - formatContextUtilization(snapshot.usage), + percent === undefined ? undefined : `${percent}% ctx`, ], this.strip.focused ? "enter open · ↑ back" : "↓ to manage", snapshot.status === "running" ? undefined : statusColor(snapshot.status), diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index e33d5fff..aadd644c 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -1,7 +1,7 @@ /** * Takeover UI for subagents (ported from v1, rendering from the synchronous * SubagentReadModel instead of live pi sessions): - * - SubagentDashboard: full popup (overlay) listing all subagents. + * - SubagentDashboard: compact picker docked above the input, listing all subagents. * - TakeoverView: full interactive view of one subagent with an input line * to steer/continue it. */ @@ -132,7 +132,14 @@ export async function openSubagentPicker( new SubagentDashboard(tui, theme, keybindings, view, selection, done), { overlay: true, - overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" }, + // Dock the picker just above the editor (editor + strip + footer ≈ 6 + // rows) like a command palette, instead of covering the conversation. + overlayOptions: { + anchor: "bottom-center", + width: "100%", + maxHeight: "60%", + margin: { bottom: 6 }, + }, }, ); @@ -144,7 +151,10 @@ export async function openSubagentPicker( } } -// --- Dashboard (fullscreen overlay) ---------------------------------------------- +// --- Dashboard (picker docked above the input) --------------------------------- + +/** A picker is a glance, not a workspace: cap the list window and scroll. */ +const MAX_PICKER_ROWS = 10; export interface DashboardSelection { id?: string; @@ -270,8 +280,10 @@ export class SubagentDashboard implements Component { // One timestamp per frame so every row's spinner shows the same frame. const now = Date.now(); + // Docked above the editor, the panel borrows conversation space: cap the + // list window and scroll instead of growing toward the top of the screen. const rows = this.tui.terminal.rows || 30; - const maxBodyHeight = Math.max(1, rows - 5); + const maxBodyHeight = Math.min(Math.max(1, rows - 5), MAX_PICKER_ROWS); const bodyHeight = subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length); const innerWidth = Math.max(0, width - 2); diff --git a/extensions/subagents/src/ui/wait-result.ts b/extensions/subagents/src/ui/wait-result.ts index e04b6f0e..8ff6cee0 100644 --- a/extensions/subagents/src/ui/wait-result.ts +++ b/extensions/subagents/src/ui/wait-result.ts @@ -26,7 +26,7 @@ export function buildWaitResultPreview( const results = details?.results ?? []; const failed = results.filter((result) => result.status === "error").length; const header = - theme.fg(failed > 0 ? "warning" : "success", "■") + + theme.fg(failed > 0 ? "warning" : "success", failed > 0 ? "!" : "✓") + ` ${theme.fg("accent", theme.bold(`${results.length} subagent${results.length === 1 ? "" : "s"} settled`))}` + (failed > 0 ? theme.fg("error", ` · ${failed} failed`) : ""); const lines = [header]; diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index 256bd5fd..1daca2d3 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -13,9 +13,9 @@ import { formatElapsed, formatTokens, statusColor, - statusSquare, type Theme, type WorkflowDetails, + type WorkflowStatus, } from "./model.ts"; /** Workflow-named aliases preserve the public seam while sharing interaction. */ @@ -34,6 +34,13 @@ function cleanLine(value: string) { return sanitizeTerminalText(value).replace(/\s+/g, " ").trim(); } +/** One status glyph per run state; doubles as the focus marker when selected. */ +function statusGlyph(status: WorkflowStatus, theme: Theme) { + if (status === "completed") return theme.fg("success", "✓"); + if (status === "running") return theme.fg("warning", "●"); + return theme.fg("error", "x"); +} + /** Live, one-line Claude-style workflow entry rendered below the editor. */ export class WorkflowStripWidget { private readonly timer: ReturnType; @@ -70,16 +77,16 @@ export class WorkflowStripWidget { const settled = done + failed; const usage = aggregateUsage(details.agents); const tokenCount = usage.input + usage.output; - const marker = this.strip.focused + const glyph = this.strip.focused ? this.theme.fg("accent", "❯") - : this.theme.fg("dim", "○"); + : statusGlyph(details.status, this.theme); const displayName = cleanLine(details.name ?? entry.runId) || entry.runId; const name = this.strip.focused ? this.theme.bold(this.theme.fg("accent", displayName)) : this.theme.fg("text", displayName); const rawContext = details.currentPhase ?? details.description; const context = rawContext ? cleanLine(rawContext) : undefined; - const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`; + const left = ` ${glyph} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`; const right = renderNavigationMetrics( this.theme, [