diff --git a/extensions/processes-dock/widget/setup.ts b/extensions/processes-dock/widget/setup.ts index 836a429..a3d167e 100644 --- a/extensions/processes-dock/widget/setup.ts +++ b/extensions/processes-dock/widget/setup.ts @@ -21,9 +21,11 @@ import { import { renderLogDock } from "../components/log-dock-component"; import { createDockState } from "../dock-state"; import { connectToProcessLogs, type LogsConnection } from "../logs-client"; +import { renderStatusWidget } from "./status"; import type { DockActions, DockState } from "./types"; const DOCK_WIDGET_KEY = "processes-dock"; +const STATUS_WIDGET_KEY = "processes-status"; const MAX_NOTIFY_MARKERS_PER_PROCESS = 100; const MAX_PREVIEW_PROCESSES = 8; const REFRESH_THROTTLE_MS = 125; @@ -114,6 +116,33 @@ export function setupDockWidgets( ); }; + const renderStatus = () => { + if (disposed) return; + if (!config.widget.showStatusWidget) { + ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, { + placement: "belowEditor", + }); + return; + } + if (processes.length === 0) { + ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, { + placement: "belowEditor", + }); + return; + } + // The width is only known at render time, so hand Pi a factory that + // renders against the actual belowEditor column count instead of baking a + // static width from process.stdout.columns (which drifts on resize / splits). + ctx.ui.setWidget( + STATUS_WIDGET_KEY, + (_tui, theme: Theme) => ({ + render: (width: number) => renderStatusWidget(processes, theme, width), + invalidate: () => undefined, + }), + { placement: "belowEditor" }, + ); + }; + const hardRefresh = () => { if (disposed) return; processes = sortProcesses(requestProcessList(events)); @@ -147,6 +176,7 @@ export function setupDockWidgets( } render(); + renderStatus(); }; const scheduleRefresh = () => { @@ -357,6 +387,9 @@ export function setupDockWidgets( ctx.ui.setWidget(DOCK_WIDGET_KEY, undefined, { placement: "aboveEditor", }); + ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, { + placement: "belowEditor", + }); }, }; } diff --git a/extensions/processes-dock/widget/status.test.ts b/extensions/processes-dock/widget/status.test.ts new file mode 100644 index 0000000..2e7df78 --- /dev/null +++ b/extensions/processes-dock/widget/status.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import type { ProcessInfo } from "../../../src/types"; +import { renderStatusWidget } from "./status"; + +function makeProcess(overrides: Partial = {}): ProcessInfo { + return { + id: "proc_1", + name: "dev", + pid: 123, + command: "pnpm dev", + cwd: "/repo", + startTime: 1000, + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: "/tmp/stdout.log", + stderrFile: "/tmp/stderr.log", + endReason: null, + signal: null, + errorMessage: null, + ...overrides, + }; +} + +// `theme.fg(color, text)` -> `{color:text}` keeps assertions legible. `bg` +// is only relevant to the tab renderer, not the status widget, but is +// included for completeness. +const theme = { + fg: (color: string, text: string) => `{${color}:${text}}`, + bg: (_color: string, text: string) => text, +} as never; + +describe("renderStatusWidget", () => { + it("renders nothing when there are no processes", () => { + expect(renderStatusWidget([], theme)).toEqual([]); + }); + + it("renders a single running process with a dot, name, and state", () => { + const lines = renderStatusWidget( + [makeProcess({ status: "running" })], + theme, + ); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("{dim:processes: }"); + expect(lines[0]).toContain("{accent:dev}"); + expect(lines[0]).toContain("{dim:running}"); + }); + + it("renders a successful exit as done", () => { + const lines = renderStatusWidget( + [ + makeProcess({ + status: "exited", + success: true, + exitCode: 0, + endTime: 2000, + }), + ], + theme, + ); + expect(lines[0]).toContain("{success:done}"); + }); + + it("renders a failed exit with the code", () => { + const lines = renderStatusWidget( + [ + makeProcess({ + status: "exited", + success: false, + exitCode: 7, + endTime: 2000, + }), + ], + theme, + ); + expect(lines[0]).toContain("{error:exit(7)}"); + }); + + it("joins multiple processes with a dim separator", () => { + const lines = renderStatusWidget( + [ + makeProcess({ id: "proc_1", name: "dev", status: "running" }), + makeProcess({ + id: "proc_2", + name: "test", + status: "exited", + success: true, + exitCode: 0, + endTime: 2000, + }), + ], + theme, + ); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("{dim: | }"); + // Live processes come before finished ones. + expect(lines[0].indexOf("{accent:dev}")).toBeLessThan( + lines[0].indexOf("{success:done}"), + ); + }); + + it("fits the line to the requested width on overflow", () => { + const processes = Array.from({ length: 20 }, (_, index) => + makeProcess({ + id: `proc_${index}`, + name: `server-${index}`, + status: "running", + }), + ); + const maxWidth = 30; + const lines = renderStatusWidget(processes, theme, maxWidth); + expect(lines).toHaveLength(1); + // truncateToWidth clamps the visible portion even though the mock theme + // inflates the measured widths, so result stays within bounds. + expect(lines[0].length).toBeGreaterThan(0); + }); + + it("renders the first process even when width is tiny", () => { + const lines = renderStatusWidget( + [makeProcess({ status: "running" })], + theme, + 3, + ); + expect(lines).toHaveLength(1); + // Truncated to at most 3 visible columns. + expect(lines[0].length).toBeGreaterThan(0); + }); + + it("renders terminating as stopping with a warning dot", () => { + const lines = renderStatusWidget( + [makeProcess({ status: "terminating" })], + theme, + ); + expect(lines[0]).toContain("{warning:●}"); + expect(lines[0]).toContain("{dim:stopping}"); + }); +}); diff --git a/extensions/processes-dock/widget/status.ts b/extensions/processes-dock/widget/status.ts new file mode 100644 index 0000000..2cc92b2 --- /dev/null +++ b/extensions/processes-dock/widget/status.ts @@ -0,0 +1,114 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; +import { statusDot } from "../../shared/ui"; + +const MAX_PROCESS_NAME = 20; +const DEFAULT_MAX_WIDTH = 200; + +function formatProcessName(name: string, theme: Theme): string { + const trimmed = + name.length > MAX_PROCESS_NAME + ? `${name.slice(0, MAX_PROCESS_NAME - 3)}...` + : name; + return theme.fg("accent", trimmed); +} + +function formatProcessLabel(process: ProcessInfo, theme: Theme): string { + const name = formatProcessName(process.name, theme); + const dot = statusDot(process, true, theme); + + switch (process.status) { + case "running": + return `${dot} ${name} ${theme.fg("dim", "running")}`; + case "terminating": + return `${dot} ${name} ${theme.fg("dim", "stopping")}`; + case "terminate_timeout": + return `${dot} ${name} ${theme.fg("error", "unresponsive")}`; + case "killed": + return `${dot} ${name} ${theme.fg("dim", "killed")}`; + case "exited": + if (process.success) { + return `${dot} ${name} ${theme.fg("success", "done")}`; + } + return `${dot} ${name} ${theme.fg("error", `exit(${process.exitCode ?? "?"})`)}`; + default: + return `${dot} ${name} ${theme.fg("dim", process.status)}`; + } +} + +function sortForStatusLine(processes: ProcessInfo[]): ProcessInfo[] { + const aliveish = processes.filter((process) => + LIVE_STATUSES.has(process.status), + ); + const finished = processes.filter( + (process) => !LIVE_STATUSES.has(process.status), + ); + finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0)); + return [...aliveish, ...finished]; +} + +/** + * Render the single-line status widget shown below the editor. + * + * Lists managed processes (dot + name + state), fit to width with a + * "+N more" overflow marker. Returns an empty array when there are no + * processes so the caller can clear the widget. + */ +export function renderStatusWidget( + processes: ProcessInfo[], + theme: Theme, + maxWidth: number = DEFAULT_MAX_WIDTH, +): string[] { + if (processes.length === 0) return []; + + const ordered = sortForStatusLine(processes); + + const prefix = theme.fg("dim", "processes: "); + const prefixLen = visibleWidth(prefix); + const separator = theme.fg("dim", " | "); + const separatorLen = visibleWidth(separator); + + const parts: string[] = []; + let currentLen = prefixLen; + let includedCount = 0; + + for (const process of ordered) { + const formatted = formatProcessLabel(process, theme); + const formattedLen = visibleWidth(formatted); + const remaining = ordered.length - includedCount - 1; + const needed = + includedCount > 0 ? separatorLen + formattedLen : formattedLen; + + const reservedForSuffix = + remaining > 0 ? separatorLen + visibleWidth(`+${remaining} more`) : 0; + + if ( + currentLen + needed + reservedForSuffix > maxWidth && + includedCount > 0 + ) { + const hiddenCount = ordered.length - includedCount; + if (hiddenCount > 0) { + parts.push(theme.fg("dim", `+${hiddenCount} more`)); + } + break; + } + + parts.push(formatted); + currentLen += needed; + includedCount++; + } + + // Width too small for even one entry: show the first process anyway. + if (includedCount === 0) { + parts.push(formatProcessLabel(ordered[0] as ProcessInfo, theme)); + } + + if (parts.length === 0) return []; + + const line = prefix + parts.join(separator); + return [ + visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth) : line, + ]; +} diff --git a/extensions/processes/config/defaults.ts b/extensions/processes/config/defaults.ts index 3556a23..2f1300f 100644 --- a/extensions/processes/config/defaults.ts +++ b/extensions/processes/config/defaults.ts @@ -20,6 +20,7 @@ export const DEFAULT_CONFIG: ProcessProtocolConfig = { autoHideOnFinish: false, }, widget: { + showStatusWidget: false, dockDefaultState: "closed", dockHeight: 12, }, diff --git a/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.test.ts b/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.test.ts index 03b6416..f912ed5 100644 --- a/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.test.ts +++ b/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.test.ts @@ -32,7 +32,11 @@ describe("001 v0.9.4 to v0.10.0 config migration", () => { execution: { shellPath: "/bin/zsh" }, interception: { blockBackgroundCommands: false }, follow: { enabledByDefault: true, autoHideOnFinish: true }, - widget: { dockDefaultState: "closed", dockHeight: 9 }, + widget: { + showStatusWidget: true, + dockDefaultState: "closed", + dockHeight: 9, + }, }); }); @@ -58,6 +62,14 @@ describe("001 v0.9.4 to v0.10.0 config migration", () => { needsConfigV094ToV0100Migration({ widget: { showStatusWidget: false } as unknown as never, }), + ).toBe(false); + expect( + needsConfigV094ToV0100Migration({ + widget: { + showStatusWidget: true, + dockDefaultState: "hidden" as "closed", + } as unknown as never, + }), ).toBe(true); }); diff --git a/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts b/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts index cfc49e8..f2bb919 100644 --- a/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +++ b/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts @@ -55,6 +55,7 @@ export interface ConfigV0100 { autoHideOnFinish?: boolean; }; widget?: { + showStatusWidget?: boolean; dockDefaultState?: DockDefaultStateV0100; dockHeight?: number; }; @@ -67,19 +68,15 @@ export interface ConfigV0100 { * execution, interception, processList, output, and follow. * * Widget settings changed: - * - widget.showStatusWidget was removed with the status widget. * - widget.dockDefaultState "hidden" became "closed". * - widget.dockHeight keeps the same key but means dock log rows. + * - widget.showStatusWidget is preserved (still controls the status widget). */ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function hasOwn(record: Record, key: string): boolean { - return Object.hasOwn(record, key); -} - function setIfDefined( target: T, key: K, @@ -104,10 +101,7 @@ export function needsConfigV094ToV0100Migration( const root = config as Record; const widget = isRecord(root.widget) ? root.widget : undefined; - return ( - Boolean(widget && hasOwn(widget, "showStatusWidget")) || - widget?.dockDefaultState === "hidden" - ); + return widget?.dockDefaultState === "hidden"; } export function migrateConfigV094ToV0100(config: ConfigV094): ConfigV0100 { @@ -160,6 +154,7 @@ export function migrateConfigV094ToV0100(config: ConfigV094): ConfigV0100 { if (config.widget) { const widget: NonNullable = {}; + setIfDefined(widget, "showStatusWidget", config.widget.showStatusWidget); setIfDefined( widget, "dockDefaultState", @@ -185,5 +180,5 @@ export const configV094ToV0100Migration: Migration = { shouldRun: (config) => needsConfigV094ToV0100Migration(config), run: (config) => migrateConfigV094ToV0100(config as ConfigV094), message: - "Migrated pi-processes settings to the current schema. Removed stale status-widget settings and mapped dock hidden state to closed.", + "Migrated pi-processes settings to the current schema. Mapped the dock hidden state to closed.", }; diff --git a/extensions/processes/config/types.ts b/extensions/processes/config/types.ts index f8cfaae..f1d6def 100644 --- a/extensions/processes/config/types.ts +++ b/extensions/processes/config/types.ts @@ -31,6 +31,7 @@ export interface FollowConfig { } export interface WidgetConfig { + showStatusWidget?: boolean; dockDefaultState?: "closed" | "collapsed" | "expanded"; dockHeight?: number; } diff --git a/extensions/processes/settings/apply-setting-change.ts b/extensions/processes/settings/apply-setting-change.ts index 9a9b728..9d8f23f 100644 --- a/extensions/processes/settings/apply-setting-change.ts +++ b/extensions/processes/settings/apply-setting-change.ts @@ -8,7 +8,10 @@ import { setNestedValue } from "@aliou/pi-utils-settings"; import type { ProcessConfig } from "../config"; -const BOOLEAN_FIELDS = new Set(["interception.blockBackgroundCommands"]); +const BOOLEAN_FIELDS = new Set([ + "interception.blockBackgroundCommands", + "widget.showStatusWidget", +]); export function applySettingChange( id: string, diff --git a/extensions/processes/settings/build-sections.ts b/extensions/processes/settings/build-sections.ts index ac83e26..7b4a26a 100644 --- a/extensions/processes/settings/build-sections.ts +++ b/extensions/processes/settings/build-sections.ts @@ -46,6 +46,13 @@ export function buildSections( const logsSection: SettingsSection = { label: "Interfaces", items: [ + boolItem( + "widget.showStatusWidget", + "Status widget", + "Show a one-line summary of managed processes below the editor.", + scopedConfig.widget?.showStatusWidget, + resolved.widget.showStatusWidget, + ), buildOverviewDetailItem(scopedConfig, resolved, ctx), buildLogsDetailItem(scopedConfig, resolved, ctx), buildDockDetailItem(scopedConfig, resolved, ctx), diff --git a/src/protocol/requests.ts b/src/protocol/requests.ts index 3231460..c75effb 100644 --- a/src/protocol/requests.ts +++ b/src/protocol/requests.ts @@ -64,6 +64,7 @@ export interface ProcessProtocolConfig { autoHideOnFinish: boolean; }; widget: { + showStatusWidget: boolean; dockDefaultState: "closed" | "collapsed" | "expanded"; dockHeight: number; };