From 42f5b0b7bac75e8023d4e2cc2f2d460946026d23 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 19:05:32 +0800 Subject: [PATCH] feat(ui): breathe around docked pickers and declutter status glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docked pickers (/subagents, /ps) get one empty row of air above the panel and sit one row closer to the editor (bottom margin 5), so the panel no longer clings to the conversation nor floats far above the input. - Picker rows drop the stacked selection marker + status glyph pair: a selected row keeps its status glyph and borrows the accent tone, so running work keeps spinning under selection. - /ps rows share the spinner/check/cross vocabulary (running spins, done ✓, failed/timed out ✗, killed a muted ✓) instead of ■ squares; strips align on ✗ for failures. - The spawn card's marker becomes a quiet dim plus: a spawn adds an agent to the pool, and the strip's spinner carries the running state from there. --- extensions/background-terminals/src/ui/ps.ts | 38 ++++++++++++++------ extensions/subagents/index.ts | 6 ++-- extensions/subagents/navigation.ts | 2 +- extensions/subagents/src/ui/takeover.ts | 23 ++++++++---- extensions/subagents/takeover.test.ts | 2 +- extensions/workflows/navigation.ts | 2 +- 6 files changed, 49 insertions(+), 24 deletions(-) diff --git a/extensions/background-terminals/src/ui/ps.ts b/extensions/background-terminals/src/ui/ps.ts index 0d10761e..529897fd 100644 --- a/extensions/background-terminals/src/ui/ps.ts +++ b/extensions/background-terminals/src/ui/ps.ts @@ -21,6 +21,7 @@ import { panelFrame, screenTitleLine, } from "../../../shared/screen-chrome.ts"; +import { spinnerFrame } from "../../../shared/spinner.ts"; import { formatDuration, formatElapsed, @@ -43,18 +44,30 @@ function configuredKeys( return keybindings.getKeys(binding).join("/") || "unbound"; } -function statusGlyph(snap: TerminalSnapshot, theme: Theme) { +/** + * One status indicator per state, shared by the picker rows and the detail + * header. Running spins, in step with every other OpenPI surface. A selected + * row keeps its state glyph and borrows the accent tone. + */ +function statusGlyph( + snap: TerminalSnapshot, + theme: Theme, + now = Date.now(), + selected = false, +) { + const tone = (color: "warning" | "success" | "error" | "muted") => + selected ? ("accent" as const) : color; switch (snap.status) { case "running": - return theme.fg("warning", "■"); + return theme.fg(tone("warning"), spinnerFrame(now)); case "done": - return theme.fg("success", "■"); + return theme.fg(tone("success"), "✓"); case "failed": - return theme.fg("error", "■"); + return theme.fg(tone("error"), "✗"); case "killed": - return theme.fg("muted", "■"); + return theme.fg(tone("muted"), "✓"); case "timed_out": - return theme.fg("error", "■"); + return theme.fg(tone("error"), "✗"); } } @@ -92,13 +105,13 @@ export async function openTerminalPicker( new TerminalDashboard(tui, theme, keybindings, view, selection, done), { overlay: true, - // Dock the picker just above the editor (editor + strip + footer ≈ 6 + // Dock the picker just above the editor (editor + strip + footer ≈ 5 // rows) like a command palette, instead of blanking the conversation. overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "60%", - margin: { bottom: 6 }, + margin: { bottom: 5 }, }, }, ); @@ -246,6 +259,8 @@ class TerminalDashboard implements Component { configuredKeys(this.keybindings, binding); return [ + // One empty row of air between the conversation and the docked panel. + "", screenTitleLine( theme, "Background terminals", @@ -294,12 +309,13 @@ class TerminalDashboard implements Component { const index = start + i; const isSelected = index === this.selection.index; - // Left: marker, status square, title, dim id - const marker = isSelected ? theme.fg("accent", "❯") : " "; + // Left: one glyph — a selected row tints its status glyph instead of + // stacking a marker — then title and dim id. + const glyph = statusGlyph(snap, theme, Date.now(), isSelected); const title = isSelected ? theme.fg("accent", oneLine(snap.title)) : theme.fg("text", oneLine(snap.title)); - const left = ` ${marker} ${statusGlyph(snap, theme)} ${title} ${theme.fg("dim", snap.id)}`; + const left = ` ${glyph} ${title} ${theme.fg("dim", snap.id)}`; // Right: pid · elapsed · exit/status const dot = theme.fg("dim", " · "); diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 2245be50..9cabfc9f 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -810,10 +810,10 @@ export default function (pi: ExtensionAPI) { const meta = [details.harness, details.model] .filter(Boolean) .join(" \u00b7 "); - // A spawn is a beginning, not a success: keep the marker quiet and let - // the strip's spinner carry the running state from here on. + // A spawn adds an agent to the pool: a quiet plus, then the strip's + // spinner carries the running state from here on. return new Text( - `${theme.fg("dim", "\u25cf")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`, + `${theme.fg("dim", "+")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`, 0, 0, ); diff --git a/extensions/subagents/navigation.ts b/extensions/subagents/navigation.ts index 30050f92..becf7b45 100644 --- a/extensions/subagents/navigation.ts +++ b/extensions/subagents/navigation.ts @@ -68,7 +68,7 @@ function statusGlyph(snapshot: SubagentSnapshot, theme: Theme, now: number) { if (snapshot.status === "running") return theme.fg("warning", spinnerFrame(now)); if (snapshot.status === "done") return theme.fg("success", "✓"); - return theme.fg("error", "x"); + return theme.fg("error", "✗"); } /** One-line subagent manager entry with the same affordance as Workflow. */ diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index aadd644c..43768ba2 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -49,14 +49,19 @@ function statusGlyph( snap: SubagentSnapshot, theme: Theme, now = Date.now(), + selected = false, ): string { + // A selected row keeps its state glyph and borrows the accent tone, so the + // list never hides what is still running behind a selection marker. + const tone = (color: "warning" | "success" | "error") => + selected ? ("accent" as const) : color; switch (snap.status) { case "running": - return theme.fg("warning", spinnerFrame(now)); + return theme.fg(tone("warning"), spinnerFrame(now)); case "done": - return theme.fg("success", "✓"); + return theme.fg(tone("success"), "✓"); case "error": - return theme.fg("error", "✗"); + return theme.fg(tone("error"), "✗"); } } @@ -132,13 +137,13 @@ export async function openSubagentPicker( new SubagentDashboard(tui, theme, keybindings, view, selection, done), { overlay: true, - // Dock the picker just above the editor (editor + strip + footer ≈ 6 + // Dock the picker just above the editor (editor + strip + footer ≈ 5 // rows) like a command palette, instead of covering the conversation. overlayOptions: { anchor: "bottom-center", width: "100%", maxHeight: "60%", - margin: { bottom: 6 }, + margin: { bottom: 5 }, }, }, ); @@ -306,6 +311,8 @@ export class SubagentDashboard implements Component { // /workflows. The frame is padded to the rows it was given, which keeps // this view's content-fit height rather than reintroducing a fixed one. return [ + // One empty row of air between the conversation and the docked panel. + "", ...panelFrame(theme, { label: `Subagents · ${summary}`, rows: rowLines, @@ -353,13 +360,15 @@ export class SubagentDashboard implements Component { const index = start + i; const isSelected = index === this.selection.index; - const marker = isSelected ? theme.fg("accent", "❯") : " "; + // One glyph column: a selected row tints its status glyph with the + // accent tone instead of stacking a second marker. + const glyph = statusGlyph(snap, theme, now, isSelected); const safeTitle = sanitizeSubagentDisplayLine(snap.title) || snap.id; const title = isSelected ? theme.fg("accent", safeTitle) : theme.fg("text", safeTitle); const activity = runningActivity(snap); - const prefix = ` ${marker} ${statusGlyph(snap, theme, now)} `; + const prefix = ` ${glyph} `; const utilization = formatContextUtilization(snap.usage); const metadata = [ diff --git a/extensions/subagents/takeover.test.ts b/extensions/subagents/takeover.test.ts index 64b7f4df..596a2292 100644 --- a/extensions/subagents/takeover.test.ts +++ b/extensions/subagents/takeover.test.ts @@ -117,7 +117,7 @@ test("dashboard box height follows its subagents and retains the old maximum", ( const one = dashboard([snap("one")], 10); try { const lines = one.render(100); - assert.equal(lines.length, 4); // border, one agent, border, hints + assert.equal(lines.length, 5); // air, border, one agent, border, hints assert.equal(lines.filter((line) => line.includes("agent one")).length, 1); } finally { one.dispose(); diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index 313cd82b..42922bbc 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -42,7 +42,7 @@ function cleanLine(value: string) { function statusGlyph(status: WorkflowStatus, theme: Theme, now: number) { if (status === "completed") return theme.fg("success", "✓"); if (status === "running") return theme.fg("warning", spinnerFrame(now)); - return theme.fg("error", "x"); + return theme.fg("error", "✗"); } /** Live, one-line Claude-style workflow entry rendered below the editor. */