diff --git a/extensions/process-tabs.ts b/extensions/process-tabs.ts index ae86931..28bb80d 100644 --- a/extensions/process-tabs.ts +++ b/extensions/process-tabs.ts @@ -1,6 +1,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import { truncateToWidth } from "@earendil-works/pi-tui"; -import { LIVE_STATUSES, type ProcessInfo } from "../src/types"; +import type { ProcessInfo } from "../src/types"; +import { statusDot } from "./shared/ui"; const MAX_TAB_NAME = 12; @@ -9,23 +10,11 @@ export function renderProcessTab( active: boolean, theme: Theme, ): string { - const dot = renderProcessTabDot(process, active, theme); + const dot = statusDot(process, active, theme); const label = truncateToWidth(process.name, MAX_TAB_NAME, "", true); return active ? theme.bg("selectedBg", ` ${dot} ${theme.fg("accent", label)} `) : ` ${dot} ${theme.fg("dim", label)} `; } -export function renderProcessTabDot( - process: ProcessInfo, - active: boolean, - theme: Theme, -): string { - if (process.success === false && process.status !== "killed") { - return theme.fg("error", "!"); - } - if (LIVE_STATUSES.has(process.status)) { - return active ? theme.fg("accent", "●") : theme.fg("dim", "○"); - } - return theme.fg("dim", "■"); -} +export { statusDot as renderProcessTabDot }; diff --git a/extensions/processes-dock/commands/pin.ts b/extensions/processes-dock/commands/pin.ts index e8cb0f1..f5bd922 100644 --- a/extensions/processes-dock/commands/pin.ts +++ b/extensions/processes-dock/commands/pin.ts @@ -9,7 +9,7 @@ import { type SelectItem, SelectList, } from "@earendil-works/pi-tui"; -import { formatStatus } from "../../../src/utils/format"; +import { formatProcessSelectionDescription } from "../../shared/ui"; import { requestProcess, requestProcessList } from "../client"; import type { DockController } from "../widget/setup"; @@ -85,7 +85,10 @@ async function pickPinTarget( ...processes.map((process) => ({ label: `${process.name} (${process.id})`, value: process.id, - description: `${formatStatus(process)} — ${process.command}${process.id === pinnedProcessId ? " — pinned" : ""}`, + description: formatProcessSelectionDescription( + process, + process.id === pinnedProcessId ? " — pinned" : "", + ), })), ]; @@ -147,7 +150,7 @@ function completions( .map((process) => ({ value: process.id, label: `${process.name} (${process.id})`, - description: `${formatStatus(process)} — ${process.command}`, + description: formatProcessSelectionDescription(process), })); const items = [...clearItems, ...processItems]; return items.length > 0 ? items : null; diff --git a/extensions/processes-dock/components/log-dock-component.ts b/extensions/processes-dock/components/log-dock-component.ts index 001f6b2..ada41ab 100644 --- a/extensions/processes-dock/components/log-dock-component.ts +++ b/extensions/processes-dock/components/log-dock-component.ts @@ -9,6 +9,7 @@ import type { ProcessInfo } from "../../../src/types"; import { LIVE_STATUSES } from "../../../src/types"; import { stripAnsi } from "../../../src/utils/ansi"; import { renderProcessTab } from "../../process-tabs"; +import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui"; import type { ProcessLogLine } from "../client"; import type { DockState } from "../widget/types"; @@ -282,33 +283,3 @@ function padName(value: string, width: number): string { const name = truncateToWidth(value, width, "", true); return `${name}${" ".repeat(Math.max(0, width - visibleWidth(name)))}`; } - -class LineComponent implements Component { - constructor(private readonly renderLine: (width: number) => string) {} - - render(width: number): string[] { - return [this.renderLine(width)]; - } - - invalidate(): void {} -} - -class LinesComponent implements Component { - constructor(private readonly renderLines: (width: number) => string[]) {} - - render(width: number): string[] { - return this.renderLines(width); - } - - invalidate(): void {} -} - -class RuleComponent implements Component { - constructor(private readonly theme: Theme) {} - - render(width: number): string[] { - return [this.theme.fg("dim", "─".repeat(Math.max(0, width)))]; - } - - invalidate(): void {} -} diff --git a/extensions/processes-dock/components/panel-helpers.ts b/extensions/processes-dock/components/panel-helpers.ts deleted file mode 100644 index 7435886..0000000 --- a/extensions/processes-dock/components/panel-helpers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Theme } from "@earendil-works/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; - -export function createPanelPadder(width: number): (content: string) => string { - return (content: string): string => { - const truncated = truncateToWidth(content, width, "", true); - const padding = " ".repeat(Math.max(0, width - visibleWidth(truncated))); - return `${truncated}${padding}`; - }; -} - -export function renderPanelRule(width: number, theme: Theme): string { - if (width <= 0) return ""; - return theme.fg("dim", "─".repeat(width)); -} - -export function renderPanelTitleLine( - title: string, - width: number, - theme: Theme, -): string { - if (width <= 0) return ""; - - const label = ` ${title} `; - const truncated = truncateToWidth(label, width, "", true); - const remaining = Math.max(0, width - visibleWidth(truncated)); - - return theme.fg("dim", `${truncated}${"─".repeat(remaining)}`); -} diff --git a/extensions/processes-logs/completions.ts b/extensions/processes-logs/completions.ts index c901632..ed0e86a 100644 --- a/extensions/processes-logs/completions.ts +++ b/extensions/processes-logs/completions.ts @@ -1,6 +1,6 @@ import type { EventBus } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; -import { formatStatus } from "../../src/utils/format"; +import { formatProcessSelectionDescription } from "../shared/ui"; import { requestProcessList } from "./client"; export function allProcessCompletions( @@ -24,7 +24,7 @@ function buildCompletions( .map((process) => ({ value: process.id, label: `${process.name} (${process.id})`, - description: `${formatStatus(process)} — ${process.command}`, + description: formatProcessSelectionDescription(process), })); return items.length > 0 ? items : null; diff --git a/extensions/processes-logs/components/log-overlay-component.ts b/extensions/processes-logs/components/log-overlay-component.ts index 76aea39..195a819 100644 --- a/extensions/processes-logs/components/log-overlay-component.ts +++ b/extensions/processes-logs/components/log-overlay-component.ts @@ -18,6 +18,12 @@ import { import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; import { formatRuntime, truncateCmd } from "../../../src/utils/format"; import { isRecord } from "../../../src/utils/is-record"; +import { + LineComponent, + LinesComponent, + RuleComponent, + statusDot, +} from "../../shared/ui"; import { requestProcessList } from "../client"; import { connectToProcessLogs, @@ -593,17 +599,7 @@ export class LogOverlayComponent implements Component { } private renderTabDot(process: ProcessInfo, active: boolean): string { - const t = this.opts.theme; - if (process.status === "running") { - return active ? t.fg("accent", "●") : t.fg("dim", "○"); - } - if (process.status === "exited" && process.success) { - return t.fg("success", "●"); - } - if (process.status === "terminating") { - return t.fg("warning", "●"); - } - return t.fg("error", "●"); + return statusDot(process, active, this.opts.theme); } private renderFooterKeys(width: number): string { @@ -634,36 +630,6 @@ export class LogOverlayComponent implements Component { } } -class LineComponent implements Component { - constructor(private readonly renderLine: (width: number) => string) {} - - render(width: number): string[] { - return [this.renderLine(width)]; - } - - invalidate(): void {} -} - -class LinesComponent implements Component { - constructor(private readonly renderLines: (width: number) => string[]) {} - - render(width: number): string[] { - return this.renderLines(width); - } - - invalidate(): void {} -} - -class RuleComponent implements Component { - constructor(private readonly theme: Theme) {} - - render(width: number): string[] { - return [this.theme.fg("dim", "─".repeat(Math.max(0, width)))]; - } - - invalidate(): void {} -} - function isLogsConnectionError( connection: LogsConnection | { ok: false; error: string }, ): connection is { ok: false; error: string } { diff --git a/extensions/processes/commands/kill.ts b/extensions/processes/commands/kill.ts index 8b0b933..9003228 100644 --- a/extensions/processes/commands/kill.ts +++ b/extensions/processes/commands/kill.ts @@ -10,7 +10,7 @@ import { SelectList, } from "@earendil-works/pi-tui"; import { LIVE_STATUSES } from "../../../src/types"; -import { formatStatus } from "../../../src/utils/format"; +import { formatProcessSelectionDescription } from "../../shared/ui"; import { requestKill, requestProcess, requestProcessList } from "../client"; /** @@ -94,7 +94,7 @@ async function pickTarget( .map((process) => ({ label: `${process.name} (${process.id})`, value: process.id, - description: `${formatStatus(process)} — ${process.command}`, + description: formatProcessSelectionDescription(process), })); if (items.length === 0) { @@ -146,7 +146,7 @@ function completions( .map((process) => ({ value: process.id, label: `${process.name} (${process.id})`, - description: `${formatStatus(process)} — ${process.command}`, + description: formatProcessSelectionDescription(process), })); return items.length > 0 ? items : null; } diff --git a/extensions/processes/components/overview-component.ts b/extensions/processes/components/overview-component.ts index 852389d..7cf2d00 100644 --- a/extensions/processes/components/overview-component.ts +++ b/extensions/processes/components/overview-component.ts @@ -17,6 +17,11 @@ import { import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; import { formatRuntime, truncateCmd } from "../../../src/utils/format"; import { isRecord } from "../../../src/utils/is-record"; +import { + LineComponent, + LinesComponent, + processStatusTone, +} from "../../shared/ui"; import { type ProcessLogLine, requestClear, @@ -60,7 +65,6 @@ const MIN_OVERVIEW_WIDTH = 40; const PREVIEW_HEIGHT = 8; type Mode = "normal" | "filter-typing"; -type ProcessStatusTone = "success" | "warning" | "error" | "muted"; /** * Full-screen overview of managed processes. Replaces the editor while open. @@ -651,45 +655,9 @@ export class OverviewComponent implements Component { } } -// --- small helper components (mirror the logs overlay idiom) --- - -class LineComponent implements Component { - constructor(private readonly renderLine: (width: number) => string) {} - render(width: number): string[] { - return [this.renderLine(width)]; - } - invalidate(): void {} -} - -class LinesComponent implements Component { - constructor(private readonly renderLines: (width: number) => string[]) {} - render(width: number): string[] { - return this.renderLines(width); - } - invalidate(): void {} -} - // --- formatting helpers (pure, unit-testable) --- -export function renderStatusDot( - process: ProcessInfo, - active: boolean, - theme: Theme, -): string { - if (process.success === false && process.status !== "killed") { - return theme.fg("error", "!"); - } - if (process.status === "terminating") { - return theme.fg("warning", "●"); - } - if (LIVE_STATUSES.has(process.status)) { - return active ? theme.fg("accent", "●") : theme.fg("dim", "○"); - } - if (process.status === "exited" && process.success) { - return theme.fg("success", "●"); - } - return theme.fg("dim", "■"); -} +export { statusDot as renderStatusDot } from "../../shared/ui"; export function formatStatusShort(process: ProcessInfo): string { if (LIVE_STATUSES.has(process.status)) return process.status; @@ -700,15 +668,7 @@ export function formatStatusShort(process: ProcessInfo): string { } function formatColoredStatusShort(process: ProcessInfo, theme: Theme): string { - return theme.fg(getProcessStatusTone(process), formatStatusShort(process)); -} - -function getProcessStatusTone(process: ProcessInfo): ProcessStatusTone { - if (process.status === "running") return "success"; - if (process.status === "terminating") return "warning"; - if (process.status === "terminate_timeout") return "error"; - if (process.status === "killed") return "warning"; - return process.success ? "muted" : "error"; + return theme.fg(processStatusTone(process), formatStatusShort(process)); } /** Sort + filter view used by the overview. Pure, unit-testable. */ diff --git a/extensions/processes/tools/utils.ts b/extensions/processes/tools/utils.ts index bcce0d6..7ee4c9a 100644 --- a/extensions/processes/tools/utils.ts +++ b/extensions/processes/tools/utils.ts @@ -7,14 +7,13 @@ import { formatStatus, formatTimestamp, } from "../../../src/utils"; +import { processStatusTone } from "../../shared/ui"; import { truncateToWidth } from "../utils/truncate"; export interface RenderOptions { expanded?: boolean; } -export type ProcessStatusTone = "success" | "warning" | "error" | "muted"; - export function buildField( label: string, value: string | number | null, @@ -100,15 +99,7 @@ export function formatColoredProcessStatus( process: ProcessInfo, theme: Theme, ): string { - return theme.fg(getProcessStatusTone(process), formatStatus(process)); -} - -export function getProcessStatusTone(process: ProcessInfo): ProcessStatusTone { - if (process.status === "running") return "success"; - if (process.status === "terminating") return "warning"; - if (process.status === "terminate_timeout") return "error"; - if (process.status === "killed") return "warning"; - return process.success ? "muted" : "error"; + return theme.fg(processStatusTone(process), formatStatus(process)); } export function plural(noun: string, count: number): string { diff --git a/extensions/shared/ui.test.ts b/extensions/shared/ui.test.ts new file mode 100644 index 0000000..06baa18 --- /dev/null +++ b/extensions/shared/ui.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { ProcessInfo } from "../../src/types"; +import { + formatProcessSelectionDescription, + LineComponent, + LinesComponent, + processStatusTone, + RuleComponent, + statusDot, +} from "./ui"; + +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}` makes assertions trivial. +const theme = { + fg: (color: string, text: string) => `{${color}:${text}}`, + bg: (_color: string, text: string) => text, +}; + +describe("LineComponent", () => { + it("renders a single line from the callback", () => { + const component = new LineComponent((width) => `x:${width}`); + expect(component.render(10)).toEqual(["x:10"]); + expect(component.invalidate()).toBeUndefined(); + }); +}); + +describe("LinesComponent", () => { + it("renders multiple lines from the callback", () => { + const component = new LinesComponent((width) => [ + `a:${width}`, + `b:${width}`, + ]); + expect(component.render(5)).toEqual(["a:5", "b:5"]); + }); +}); + +describe("RuleComponent", () => { + it("renders a dim rule sized to width", () => { + const component = new RuleComponent(theme as never); + expect(component.render(3)).toEqual(["{dim:───}"]); + }); + + it("clamps to non-negative width", () => { + const component = new RuleComponent(theme as never); + expect(component.render(0)).toEqual(["{dim:}"]); + }); +}); + +describe("processStatusTone", () => { + it("returns success for running", () => { + expect(processStatusTone(makeProcess({ status: "running" }))).toBe( + "success", + ); + }); + + it("returns warning for terminating and killed", () => { + expect(processStatusTone(makeProcess({ status: "terminating" }))).toBe( + "warning", + ); + expect( + processStatusTone(makeProcess({ status: "killed", success: false })), + ).toBe("warning"); + }); + + it("returns error for terminate_timeout and failed exits", () => { + expect( + processStatusTone( + makeProcess({ status: "terminate_timeout", success: false }), + ), + ).toBe("error"); + expect( + processStatusTone( + makeProcess({ status: "exited", success: false, exitCode: 1 }), + ), + ).toBe("error"); + }); + + it("returns muted for a successful exit", () => { + expect( + processStatusTone( + makeProcess({ status: "exited", success: true, exitCode: 0 }), + ), + ).toBe("muted"); + }); +}); + +describe("statusDot", () => { + it("uses error '!' for failed non-killed processes", () => { + expect( + statusDot( + makeProcess({ status: "exited", success: false, exitCode: 1 }), + false, + theme as never, + ), + ).toBe("{error:!}"); + }); + + it("uses accent dot for an active running process", () => { + expect( + statusDot(makeProcess({ status: "running" }), true, theme as never), + ).toBe("{accent:●}"); + }); + + it("uses dim square for killed processes (not '!')", () => { + expect( + statusDot( + makeProcess({ status: "killed", success: false }), + false, + theme as never, + ), + ).toBe("{dim:■}"); + }); +}); + +describe("formatProcessSelectionDescription", () => { + it("formats status and command with an em dash", () => { + expect(formatProcessSelectionDescription(makeProcess())).toBe( + "running — pnpm dev", + ); + }); + + it("appends a suffix when provided", () => { + expect(formatProcessSelectionDescription(makeProcess(), " — pinned")).toBe( + "running — pnpm dev — pinned", + ); + }); + + it("omits the suffix when empty", () => { + expect(formatProcessSelectionDescription(makeProcess(), "")).toBe( + "running — pnpm dev", + ); + }); +}); diff --git a/extensions/shared/ui.ts b/extensions/shared/ui.ts new file mode 100644 index 0000000..2fa1bd6 --- /dev/null +++ b/extensions/shared/ui.ts @@ -0,0 +1,113 @@ +/** + * Shared extension-UI primitives used across the processes, processes-logs, + * and processes-dock extensions. + * + * This module lives outside pi-agnostic `src/` because it depends on the Pi + * TUI (`Component`, `Theme`). It consolidates the tiny render-component + * copies and the status-display helpers that were duplicated across the three + * extensions. + */ + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; + +import { LIVE_STATUSES, type ProcessInfo } from "../../src/types"; +import { formatStatus } from "../../src/utils/format"; + +// --------------------------------------------------------------------------- +// Render components +// --------------------------------------------------------------------------- + +/** Render a single line produced by a width-aware callback. */ +export class LineComponent implements Component { + constructor(private readonly renderLine: (width: number) => string) {} + + render(width: number): string[] { + return [this.renderLine(width)]; + } + + invalidate(): void {} +} + +/** Render multiple lines produced by a width-aware callback. */ +export class LinesComponent implements Component { + constructor(private readonly renderLines: (width: number) => string[]) {} + + render(width: number): string[] { + return this.renderLines(width); + } + + invalidate(): void {} +} + +/** Render a horizontal rule line sized to the available width. */ +export class RuleComponent implements Component { + constructor(private readonly theme: Theme) {} + + render(width: number): string[] { + return [this.theme.fg("dim", "─".repeat(Math.max(0, width)))]; + } + + invalidate(): void {} +} + +// --------------------------------------------------------------------------- +// Status formatting +// --------------------------------------------------------------------------- + +export type ProcessStatusTone = "success" | "warning" | "error" | "muted"; + +/** + * Resolve the tone for a process's current status. Shared so the overview, + * dock, and output tool agree on coloring. + */ +export function processStatusTone(process: ProcessInfo): ProcessStatusTone { + if (process.status === "running") return "success"; + if (process.status === "terminating") return "warning"; + if (process.status === "terminate_timeout") return "error"; + if (process.status === "killed") return "warning"; + return process.success ? "muted" : "error"; +} + +/** + * Render the status indicator dot for a process. + * + * - `!` (error) for failures/crashes, unless the process was intentionally + * killed. + * - `●` (warning) for terminating. + * - `●`/`○` (accent/dim) for live processes, depending on `active`. + * - `●` (success) for a clean exit. + * - `■` (dim) for other terminal states (e.g. killed). + */ +export function statusDot( + process: ProcessInfo, + active: boolean, + theme: Theme, +): string { + if (process.success === false && process.status !== "killed") { + return theme.fg("error", "!"); + } + if (process.status === "terminating") { + return theme.fg("warning", "●"); + } + if (LIVE_STATUSES.has(process.status)) { + return active ? theme.fg("accent", "●") : theme.fg("dim", "○"); + } + if (process.status === "exited" && process.success) { + return theme.fg("success", "●"); + } + return theme.fg("dim", "■"); +} + +/** + * Build the `description` string for process picker items and autocomplete + * completions: `"running — pnpm dev"`. Shared so `/ps:kill`, `/ps:logs`, + * `/ps:pin`, and the logs completions stay consistent. + */ +export function formatProcessSelectionDescription( + process: ProcessInfo, + suffix = "", +): string { + const base = `${formatStatus(process)} — ${process.command}`; + return suffix ? `${base}${suffix}` : base; +}