From 94f5cd2a3e1b366acb2e5771b3c1256c6df24e4d Mon Sep 17 00:00:00 2001 From: Kaiabot Date: Fri, 2 Oct 2026 20:29:00 +0000 Subject: [PATCH] feat(dock): fold failed and killed processes into status summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pendingTerminalIds to the dock widget: a failed or killed process stays visible by name until the next process starts, then folds into the new `! N failed` / `■ N killed` summary tokens, mirroring `✓ N done`. The set holds only non-folded ids and is pruned when processes leave the registry, so it stays small over long sessions. --- .changeset/failed-summary-token.md | 5 + .../processes-dock/widget/setup.test.ts | 110 +++++++++++++++++- extensions/processes-dock/widget/setup.ts | 23 +++- .../processes-dock/widget/status.test.ts | 110 +++++++++++++++++- extensions/processes-dock/widget/status.ts | 105 +++++++++++++---- 5 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 .changeset/failed-summary-token.md diff --git a/.changeset/failed-summary-token.md b/.changeset/failed-summary-token.md new file mode 100644 index 0000000..ce6d491 --- /dev/null +++ b/.changeset/failed-summary-token.md @@ -0,0 +1,5 @@ +--- +"@aliou/pi-processes": patch +--- + +Fold failed and killed processes into `! N failed` and `■ N killed` summary tokens in the status widget, mirroring the existing `✓ N done` token. A new failure stays individual until the next process starts, then folds into the summaries. diff --git a/extensions/processes-dock/widget/setup.test.ts b/extensions/processes-dock/widget/setup.test.ts index ca8c826..90ca44b 100644 --- a/extensions/processes-dock/widget/setup.test.ts +++ b/extensions/processes-dock/widget/setup.test.ts @@ -50,7 +50,7 @@ function defaultConfig(): ProcessProtocolConfig { }, follow: { enabledByDefault: true, autoHideOnFinish: true }, widget: { - showStatusWidget: false, + showStatusWidget: true, dockDefaultState: "collapsed", dockHeight: 10, }, @@ -59,6 +59,7 @@ function defaultConfig(): ProcessProtocolConfig { interface Harness { widgetState: Map; + widgetContent: Map; emitStarted: (info: ProcessInfo) => void; emitEnded: (info: ProcessInfo) => void; emitPin: (id: string | null) => CommandPinResult; @@ -68,6 +69,7 @@ interface Harness { function createHarness(): Harness { const events: EventBus = createEventBus(); const widgetState = new Map(); + const widgetContent = new Map(); let processList: ProcessInfo[] = []; const config = defaultConfig(); @@ -75,6 +77,7 @@ function createHarness(): Harness { const ui: ExtensionUIContext = { setWidget: ((key: string, content: unknown, _options?: unknown) => { widgetState.set(key, content === undefined ? "hidden" : "visible"); + widgetContent.set(key, content); }) as never, } as unknown as ExtensionUIContext; @@ -115,6 +118,7 @@ function createHarness(): Harness { return { widgetState, + widgetContent, emitStarted: (info: ProcessInfo) => { upsertProcess(info); events.emit(CHANNELS.STARTED, info); @@ -141,11 +145,27 @@ function createHarness(): Harness { } const DOCK_KEY = "processes-dock"; +const STATUS_KEY = "processes-status"; function dockIsVisible(h: Harness): boolean { return h.widgetState.get(DOCK_KEY) === "visible"; } +function statusLine(h: Harness): string | null { + const factory = h.widgetContent.get(STATUS_KEY) as + | (( + tui: unknown, + theme: unknown, + ) => { render: (width: number) => string[] }) + | undefined; + if (!factory) return null; + const theme = { + fg: (color: string, text: string) => `{${color}:${text}}`, + bg: (_color: string, text: string) => text, + }; + return factory(null, theme).render(200)[0] ?? null; +} + describe("dock auto-close", () => { beforeEach(() => { vi.useFakeTimers(); @@ -257,3 +277,91 @@ describe("dock auto-close", () => { } }); }); + +describe("status widget pending terminal processes", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps a failed process visible until the next process starts, then folds it", () => { + const h = createHarness(); + try { + h.emitStarted(makeProcess({ id: "proc_1", name: "lint" })); + h.emitEnded( + makeProcess({ + id: "proc_1", + name: "lint", + status: "exited", + endTime: 2000, + exitCode: 7, + success: false, + }), + ); + vi.advanceTimersByTime(130); + expect(statusLine(h)).toContain("{error:lint}"); + + h.emitStarted( + makeProcess({ id: "proc_2", name: "dev", startTime: 3000 }), + ); + vi.advanceTimersByTime(130); + expect(statusLine(h)).toContain("{error:!}"); + expect(statusLine(h)).toContain("1 failed"); + expect(statusLine(h)).not.toContain("lint"); + } finally { + h.dispose(); + } + }); + + it("keeps a killed process visible until the next process starts, then folds it", () => { + const h = createHarness(); + try { + h.emitStarted(makeProcess({ id: "proc_1", name: "server" })); + h.emitEnded( + makeProcess({ + id: "proc_1", + name: "server", + status: "killed", + endTime: 2000, + success: false, + }), + ); + vi.advanceTimersByTime(130); + expect(statusLine(h)).toContain("server"); + + h.emitStarted( + makeProcess({ id: "proc_2", name: "dev", startTime: 3000 }), + ); + vi.advanceTimersByTime(130); + expect(statusLine(h)).toContain("{dim:■}"); + expect(statusLine(h)).toContain("1 killed"); + expect(statusLine(h)).not.toContain("server"); + } finally { + h.dispose(); + } + }); + + it("folds a successful exit into the done summary immediately", () => { + const h = createHarness(); + try { + h.emitStarted(makeProcess({ id: "proc_1", name: "build" })); + h.emitEnded( + makeProcess({ + id: "proc_1", + name: "build", + status: "exited", + endTime: 2000, + exitCode: 0, + success: true, + }), + ); + vi.advanceTimersByTime(130); + expect(statusLine(h)).toContain("1 done"); + expect(statusLine(h)).not.toContain("build"); + } finally { + h.dispose(); + } + }); +}); diff --git a/extensions/processes-dock/widget/setup.ts b/extensions/processes-dock/widget/setup.ts index f85f982..4452b90 100644 --- a/extensions/processes-dock/widget/setup.ts +++ b/extensions/processes-dock/widget/setup.ts @@ -8,6 +8,7 @@ import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer"; import { CHANNELS, type CommandPinPayload, + type ProcessesEndedPayload, type ProcessesOutputChangedPayload, type ProcessProtocolNotificationPayload, } from "../../shared/protocol"; @@ -63,6 +64,8 @@ export function setupDockWidgets( let processLogStream: Array<{ processId: string; line: ProcessLogLine }> = []; let pinnedConnection: LogsConnection | null = null; let pinnedConnectionId: string | null = null; + const pendingTerminalIds = new Set(); + let hasSeenRunningProcess = false; let pendingRefresh: NodeJS.Timeout | null = null; let pendingRender: NodeJS.Timeout | null = null; @@ -148,7 +151,8 @@ export function setupDockWidgets( ctx.ui.setWidget( STATUS_WIDGET_KEY, (_tui, theme: Theme) => ({ - render: (width: number) => renderStatusWidget(processes, theme, width), + render: (width: number) => + renderStatusWidget(processes, theme, width, pendingTerminalIds), invalidate: () => undefined, }), { placement: "belowEditor" }, @@ -165,6 +169,9 @@ export function setupDockWidgets( for (const id of notifyMarkers.keys()) { if (!liveIds.has(id)) notifyMarkers.delete(id); } + for (const id of pendingTerminalIds) { + if (!liveIds.has(id)) pendingTerminalIds.delete(id); + } if (processes.some((process) => process.status === "running")) { hasSeenRunningProcess = true; } @@ -344,6 +351,7 @@ export function setupDockWidgets( // Set synchronously so a process that exits within the scheduleRefresh // throttle window still counts as "seen running" for auto-close. hasSeenRunningProcess = true; + pendingTerminalIds.clear(); if (config.widget.dockDefaultState === "expanded") state.actions.expand(); else if (config.widget.dockDefaultState === "collapsed") { state.actions.collapse(); @@ -351,6 +359,17 @@ export function setupDockWidgets( scheduleRefresh(); }; + const isTerminalFailure = (process: ProcessInfo): boolean => { + if (LIVE_STATUSES.has(process.status)) return false; + return !(process.status === "exited" && process.success); + }; + + const handleEnded = (rawPayload: unknown) => { + const process = rawPayload as ProcessesEndedPayload; + if (isTerminalFailure(process)) pendingTerminalIds.add(process.id); + scheduleRefresh(); + }; + const handleOutputChanged = (rawPayload: unknown) => { const payload = rawPayload as ProcessesOutputChangedPayload; if ( @@ -416,7 +435,7 @@ export function setupDockWidgets( }; disposers.push(events.on(CHANNELS.STARTED, handleStarted)); - disposers.push(events.on(CHANNELS.ENDED, scheduleRefresh)); + disposers.push(events.on(CHANNELS.ENDED, handleEnded)); disposers.push(events.on(CHANNELS.CHANGED, scheduleRefresh)); disposers.push(events.on(CHANNELS.OUTPUT_CHANGED, handleOutputChanged)); disposers.push( diff --git a/extensions/processes-dock/widget/status.test.ts b/extensions/processes-dock/widget/status.test.ts index 708aceb..85cd7fe 100644 --- a/extensions/processes-dock/widget/status.test.ts +++ b/extensions/processes-dock/widget/status.test.ts @@ -92,7 +92,7 @@ describe("renderStatusWidget", () => { expect(lines[0]).toContain("1 done"); }); - it("shows failed processes individually, not in the summary", () => { + it("keeps a failed process visible by name while its id is pending", () => { const lines = renderStatusWidget( [ makeProcess({ @@ -110,6 +110,8 @@ describe("renderStatusWidget", () => { }), ], theme, + 200, + new Set(["proc_2"]), ); expect(lines).toHaveLength(1); // Failed process shown individually with error glyph and name. @@ -119,6 +121,108 @@ describe("renderStatusWidget", () => { expect(lines[0]).not.toContain("done"); }); + it("folds failed processes into a summary token once no longer pending", () => { + const lines = renderStatusWidget( + [ + makeProcess({ + id: "proc_1", + status: "running", + name: "dev", + }), + makeProcess({ + id: "proc_2", + status: "exited", + success: false, + exitCode: 7, + endTime: 2000, + name: "lint", + }), + makeProcess({ + id: "proc_3", + status: "exited", + success: false, + exitCode: 1, + endTime: 1500, + name: "typecheck", + }), + ], + theme, + ); + expect(lines).toHaveLength(1); + expect(lines[0]).not.toContain("{error:lint}"); + expect(lines[0]).not.toContain("{error:typecheck}"); + expect(lines[0]).toContain("{error:!}"); + expect(lines[0]).toContain("2 failed"); + }); + + it("folds killed processes into a square-glyph summary token", () => { + const lines = renderStatusWidget( + [ + makeProcess({ + id: "proc_1", + status: "killed", + success: false, + endTime: 2000, + name: "server", + }), + makeProcess({ + id: "proc_2", + status: "killed", + success: false, + endTime: 1500, + name: "worker", + }), + ], + theme, + ); + expect(lines[0]).not.toContain("server"); + expect(lines[0]).not.toContain("worker"); + expect(lines[0]).toContain("{dim:■}"); + expect(lines[0]).toContain("2 killed"); + }); + + it("orders summaries after individuals: failed, killed, then done", () => { + const lines = renderStatusWidget( + [ + makeProcess({ + id: "proc_1", + status: "running", + name: "dev", + }), + makeProcess({ + id: "proc_2", + status: "exited", + success: false, + exitCode: 7, + endTime: 2000, + name: "lint", + }), + makeProcess({ + id: "proc_3", + status: "killed", + success: false, + endTime: 1900, + name: "server", + }), + makeProcess({ + id: "proc_4", + status: "exited", + success: true, + exitCode: 0, + endTime: 1800, + name: "build", + }), + ], + theme, + ); + const line = lines[0] as string; + const order = ["{accent:dev}", "1 failed", "1 killed", "1 done"].map( + (token) => line.indexOf(token), + ); + expect(order.every((index) => index >= 0)).toBe(true); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); + it("renders a failed exit with an error-toned name", () => { const lines = renderStatusWidget( [ @@ -130,6 +234,8 @@ describe("renderStatusWidget", () => { }), ], theme, + 200, + new Set(["proc_1"]), ); expect(lines[0]).toContain("{error:dev}"); }); @@ -148,6 +254,8 @@ describe("renderStatusWidget", () => { }), ], theme, + 200, + new Set(["proc_2"]), ); expect(lines).toHaveLength(1); expect(lines[0]).toContain("{dim: }"); diff --git a/extensions/processes-dock/widget/status.ts b/extensions/processes-dock/widget/status.ts index cdcd1eb..08cbe79 100644 --- a/extensions/processes-dock/widget/status.ts +++ b/extensions/processes-dock/widget/status.ts @@ -30,32 +30,73 @@ function formatProcessLabel(process: ProcessInfo, theme: Theme): string { return `${dot} ${name}`; } +type SummaryKind = "done" | "failed" | "killed"; + /** - * Build the summary token for exited-success processes: `✓ N done`. - * Uses a synthetic exited-success ProcessInfo so statusDot agrees with the - * rest of the UI. + * The synthetic ProcessInfo makes statusDot draw the same glyph + * (`✓` / `!` / `■`) the individual rows use. */ -function formatDoneSummary(count: number, theme: Theme): string { - const summary: ProcessInfo = { - id: "_summary", - name: "", - status: "exited", - success: true, - exitCode: 0, - } as ProcessInfo; - return `${statusDot(summary, false, theme)} ${theme.fg("dim", `${count} done`)}`; +const SUMMARY_TOKENS: Record< + SummaryKind, + { process: ProcessInfo; label: string } +> = { + done: { + process: { + id: "_summary", + name: "", + status: "exited", + success: true, + exitCode: 0, + } as ProcessInfo, + label: "done", + }, + failed: { + process: { + id: "_summary", + name: "", + status: "exited", + success: false, + exitCode: 1, + } as ProcessInfo, + label: "failed", + }, + killed: { + process: { + id: "_summary", + name: "", + status: "killed", + success: false, + exitCode: null, + } as ProcessInfo, + label: "killed", + }, +}; + +function formatSummary(kind: SummaryKind, count: number, theme: Theme): string { + const { process, label } = SUMMARY_TOKENS[kind]; + return `${statusDot(process, false, theme)} ${theme.fg("dim", `${count} ${label}`)}`; } /** * Partition processes into: - * - individual: live, failed, killed (shown one-by-one) + * - individual: live processes, plus failed/killed ids in `pendingTerminalIds` + * (a fresh failure stays visible by name until the next process starts) + * - failed: folded failures (collapsed into one `! N failed` token) + * - killed: folded kills (collapsed into one `■ N killed` token) * - exitedSuccess: clean exits (collapsed into one `✓ N done` token) */ -function partitionForStatusLine(processes: ProcessInfo[]): { +function partitionForStatusLine( + processes: ProcessInfo[], + pendingTerminalIds: ReadonlySet, +): { individual: ProcessInfo[]; + failed: ProcessInfo[]; + killed: ProcessInfo[]; exitedSuccess: ProcessInfo[]; } { const individual: ProcessInfo[] = []; + const failed: ProcessInfo[] = []; + const killed: ProcessInfo[] = []; const exitedSuccess: ProcessInfo[] = []; // Live first, then failed/killed, ordered naturally. @@ -64,21 +105,31 @@ function partitionForStatusLine(processes: ProcessInfo[]): { finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)); for (const p of [...live, ...finished]) { + if (LIVE_STATUSES.has(p.status) || pendingTerminalIds.has(p.id)) { + individual.push(p); + continue; + } + if (p.status === "killed") { + killed.push(p); + continue; + } if (p.status === "exited" && p.success) { exitedSuccess.push(p); - } else { - individual.push(p); + continue; } + failed.push(p); } - return { individual, exitedSuccess }; + return { individual, failed, killed, exitedSuccess }; } /** * Render the single-line status widget shown below the editor. * - * Lists managed processes (dot + name). Live and failed processes are shown - * individually; successfully-exited processes collapse into a single + * Lists managed processes (dot + name). Live processes are shown + * individually; failed and killed processes stay individual while their id + * is in `pendingTerminalIds` and fold into `! N failed` / `■ N killed` + * summaries afterwards; successfully-exited processes collapse into a single * `✓ N done` summary. The dot glyph encodes status; the name is colored by * status tone. Returns an empty array when there are no processes so the * caller can clear the widget. @@ -87,10 +138,14 @@ export function renderStatusWidget( processes: ProcessInfo[], theme: Theme, maxWidth: number = DEFAULT_MAX_WIDTH, + pendingTerminalIds: ReadonlySet = new Set(), ): string[] { if (processes.length === 0) return []; - const { individual, exitedSuccess } = partitionForStatusLine(processes); + const { individual, failed, killed, exitedSuccess } = partitionForStatusLine( + processes, + pendingTerminalIds, + ); const prefix = theme.fg("dim", "ps: "); const prefixLen = visibleWidth(prefix); @@ -98,13 +153,19 @@ export function renderStatusWidget( const separatorLen = visibleWidth(separator); // Build the full ordered list of display tokens: individual processes - // followed by the done-summary (if any). + // followed by the failed / killed / done summaries (if any). const tokens: string[] = []; for (const process of individual) { tokens.push(formatProcessLabel(process, theme)); } + if (failed.length > 0) { + tokens.push(formatSummary("failed", failed.length, theme)); + } + if (killed.length > 0) { + tokens.push(formatSummary("killed", killed.length, theme)); + } if (exitedSuccess.length > 0) { - tokens.push(formatDoneSummary(exitedSuccess.length, theme)); + tokens.push(formatSummary("done", exitedSuccess.length, theme)); } // Fit tokens to width, with "+N more" overflow.