From f2e9e82f6077ba1e9aa0ab32218eeeafb0b2c581 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Wed, 8 Jul 2026 21:10:30 +0200 Subject: [PATCH] feat(processes): add ps overview panel --- AGENTS.md | 5 +- NEXT.md | 186 ++--- PLAN.md | 56 +- extensions/processes/client.ts | 168 ++++ extensions/processes/commands/overview.ts | 93 +++ .../components/overview-component.test.ts | 244 ++++++ .../components/overview-component.ts | 734 ++++++++++++++++++ .../processes/components/overview-panel.ts | 177 +++++ .../components/overview-render.test.ts | 421 ++++++++++ extensions/processes/config/defaults.ts | 1 + extensions/processes/config/types.ts | 1 + extensions/processes/hooks/cleanup.ts | 5 + extensions/processes/index.ts | 12 + .../processes/settings/build-sections.ts | 56 ++ src/protocol/requests.ts | 1 + .../14-overview/01-open-select-sort-filter.md | 19 + tests/scenarios/14-overview/02-kill-clear.md | 14 + tests/scenarios/14-overview/03-pin-to-dock.md | 18 + .../14-overview/04-selection-survival.md | 14 + tests/scenarios/14-overview/readme.md | 31 + 20 files changed, 2140 insertions(+), 116 deletions(-) create mode 100644 extensions/processes/client.ts create mode 100644 extensions/processes/commands/overview.ts create mode 100644 extensions/processes/components/overview-component.test.ts create mode 100644 extensions/processes/components/overview-component.ts create mode 100644 extensions/processes/components/overview-panel.ts create mode 100644 extensions/processes/components/overview-render.test.ts create mode 100644 tests/scenarios/14-overview/01-open-select-sort-filter.md create mode 100644 tests/scenarios/14-overview/02-kill-clear.md create mode 100644 tests/scenarios/14-overview/03-pin-to-dock.md create mode 100644 tests/scenarios/14-overview/04-selection-survival.md create mode 100644 tests/scenarios/14-overview/readme.md diff --git a/AGENTS.md b/AGENTS.md index e07a484..e3c69a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,10 +31,9 @@ Avoid fixed sleeps in both unit and e2e tests. Prefer event-driven helpers that ## Structure - `src/` - pi-agnostic process management (manager, types, protocol, utils). Zero pi imports. -- `extensions/processes/` - core extension: tool registration, settings, hooks, event bridge, request/command handlers -- `extensions/processes-list/` - `/ps`, `/ps:kill`, `/ps:clear` commands and TUI components +- `extensions/processes/` - core extension: tool registration, settings, hooks, event bridge, request/command handlers, `/ps` overview panel, `/ps:settings` - `extensions/processes-logs/` - `/ps:logs` command and log overlay -- `extensions/processes-dock/` - `/ps:dock`, `/ps:pin` commands, dock widget, status widget +- `extensions/processes-dock/` - `/ps:dock`, `/ps:pin` commands, dock widget, status widget, `COMMAND_PIN` handler See `PLAN.md` for the full architecture and implementation plan. diff --git a/NEXT.md b/NEXT.md index 27e9b2d..1d877e1 100644 --- a/NEXT.md +++ b/NEXT.md @@ -1,111 +1,117 @@ # pi-processes: Next Steps -## 1. `update` tool action (core extension) -- DONE +Phases 1 through 4 (plus Phase 3 bis) of the multi-extension rewrite are +complete, and the `/ps` overview/control panel is now implemented in the core +extension. The remaining work is the `clear` LLM tool action, agent steering +guidance, and final package cleanup. Everything below is ordered by priority. -Implemented in `33df3b0`. +## 1. `clear` LLM tool action -- Rename a process (`name` field) -- Add, remove, replace, or clear log watches on a running process -- Rejects updates on non-running processes -- Revision/generation mechanism in notification registry for matcher sync -- Notification service preserves fired state on same-generation updates -- Separate `WatchUpdateItemParams` schema (supports both `index` and `pattern` for remove) -- `UpdateDetails` returns `ok`/`error` for TUI error rendering -- Expanded render shows only changes (no full process info block) -- 43 unit tests across update action, registry watch methods, and formatting -- 10 test scenarios in `tests/scenarios/11-update/` - -**Addresses:** issue #26 - -## 2. processes-logs extension - -**Commands:** `/ps:logs` + `/ps` (alias) - -**Scope:** -- Log overlay with tab bar for process selection -- Live streaming via log subscription protocol (`CHANNELS.LOGS_SUBSCRIBE`/`LOGS_CHUNK`/`LOGS_UNSUBSCRIBE`) -- Inline kill (`x`) and clear (`c`) actions via `CHANNELS.COMMAND_KILL`/`COMMAND_CLEAR` -- Search, stream filter, follow mode, scroll -- Process picker when called without argument -- Completions for process IDs - -**Config additions to core:** -- `processList`: `maxVisibleProcesses`, `maxPreviewLines` -- `output`: `defaultTailLines`, `maxOutputLines` -- `follow`: `enabledByDefault`, `autoHideOnFinish` - -**Files:** -- `extensions/processes-logs/` — new directory - - `index.ts` - - `commands/logs.ts` - - `components/log-overlay-component.ts` - - `components/log-file-viewer.ts` - - `components/process-picker-component.ts` - - `components/status-format.ts` - - `completions.ts` - - `logs-client.ts` - -**No dependency on `update` or dock.** Uses only `pi.events` channels. - -## 3. processes-dock extension - -**Commands:** `/ps:dock`, `/ps:pin` +Next step. Restores parity with `main` for clearing finished processes from +the tool surface. **Scope:** -- Dock widget (above editor) with collapsed/open modes -- Status widget (below editor) -- `/ps:dock show|hide|toggle` -- `/ps:pin` to focus dock on a process -- Documented as a reference extension for building custom process UIs -**Config additions to core:** -- `widget`: `showStatusWidget`, `dockDefaultState`, `dockHeight` +- New `process clear` action (no `id` required). +- Calls `manager.clearFinished()` through a tool-level execute path (the manager + already owns this; `COMMAND_CLEAR` already exposes it to UI extensions). +- Emits `processes_changed` (already does). +- Renders: number cleared. Minimal result block. -**Files:** -- `extensions/processes-dock/` — new directory - - `index.ts` - - `commands/dock.ts` - - `commands/pin.ts` - - `components/log-dock-component.ts` - - `widget/setup.ts` - - `widget/status-widget.ts` - - `widget/types.ts` - - `dock-state.ts` - - `logs-client.ts` +**Not included:** -**No dependency on `update` or logs.** Uses only `pi.events` channels. +- `write` tool action is intentionally not planned for the rewrite. + `ProcessManager.writeToStdin()` stays an internal API only. -## 4. Final cleanup +## 2. Agent steering guidance -- Update `package.json` extensions array -- Remove dead `skills/pi-processes` reference -- Import audit (src/ stays pi-agnostic, UI extensions don't import manager) -- Update AGENTS.md structure section +Tighten promptGuidelines and ship the skill to steer agents away from common +mistakes identified during PR #33 review. -## 5. Agent steering guidance +### promptGuidelines rewrite (~5 lines) -Tighten promptGuidelines and ship the skill to steer agents away from common mistakes. To be done after steps 2-3. +Merge the existing 8 lines into ~5 tighter ones. Fold in: -### promptGuidelines rewrite (~5 lines) -- Merge existing 8 lines into ~5 tighter ones -- Fold in: list before start, use watches not polling, use update not restart, output is for targeted inspection only, don't re-summarize tool output +- list before start +- use watches instead of polling output +- use `update` instead of restarting to change watches +- `output` is for targeted inspection only (not deep reads — use `read` on the + log paths from `list`/`output`) +- do not re-summarize tool output back to the user ### Skill: `skills/pi-processes/SKILL.md` -- Recreate (was removed in `58aad4b`) -- Expanded guidance with bad/good examples for common mistakes: - - Polling output instead of setting watches - - Restarting instead of updating watches - - Starting duplicate processes - - Not watching for common failure patterns (EADDRINUSE, etc.) - - Re-summarizing tool output to the user - - Using output for deep inspection (use logs/read instead) - - Vague process names - - Leaving obsolete processes running + +Recreate (was removed in `58aad4b`). Expanded guidance with bad/good examples for +common mistakes: + +- Polling output instead of setting watches +- Restarting instead of updating watches +- Starting duplicate processes (list first) +- Not watching for common failure patterns (EADDRINUSE, etc.) +- Re-summarizing tool output to the user +- Using `output` for deep inspection (use logs/`read` instead) +- Vague process names +- Leaving obsolete processes running ### Steering text in tool output -- Add to `formatStartDetails` when watches are active: "Continue other work; watch notifications will trigger follow-up." -- Add to `formatOutputDetails` when process is still running: "Process is still running. Use watches instead of polling." + +- Add to `formatStartDetails` when watches are active: "Continue other work; + watch notifications will trigger follow-up." +- Add to `formatOutputDetails` when the process is still running: "Process is + still running. Use watches instead of polling." + +## 3. Final package/config cleanup + +Last step, after 1-2 land. + +- Update `package.json`: confirm the `extensions` array lists the three + extensions (`processes`, `processes-logs`, `processes-dock`). The `/ps` + panel lives in core, so it does not add a fourth entry. Remove the stale + `pi.skills` / `files` entry pointing at the missing `./skills/pi-processes` + directory, or restore the skill once step 3 ships the new `SKILL.md`. +- Import audit: + - `src/` has zero imports from `@earendil-works/pi-coding-agent`, + `@earendil-works/pi-tui`, `@aliou/pi-utils-settings`, `@aliou/pi-utils-ui`. + - All UI extensions (`processes-logs`, `processes-dock`, and the new panel) + have zero imports from `src/manager` or `src/get-manager`. + - Only `extensions/processes/` imports `getManager`. +- Update `AGENTS.md` structure section to reflect the new directory layout. +- Full manual QA pass through all commands and tool actions, plus a reload test + (start processes, reload, verify shutdown behavior). ## Parallelism -Steps 2 and 3 are independent of each other. They can be done in any order or in parallel. Step 5 comes after both. Step 4 is last. +Step 1 (`clear` tool) and step 2 (steering) are independent and can be done in +parallel. Step 3 (cleanup) is last. The `/ps` overview panel is complete. + +## Done + +- `/ps` overview/control panel — implemented in the core `extensions/processes/` + extension. Full-screen editor-replacement overview with a local vendored + header-capable panel (`overview-panel.ts`), selectable fixed-height process + rows (`processList.maxVisibleProcesses` means process-list content rows), + sortable/filterable list, colored statuses, bounded recent-output preview, + kill/clear actions, and dock pin/unpin via `CHANNELS.COMMAND_PIN`. Talks to + the manager only over `pi.events` channels (`REQUEST_LIST`, + `REQUEST_COMBINED_OUTPUT`, `COMMAND_KILL`, `COMMAND_CLEAR`, `COMMAND_PIN`) via + `extensions/processes/client.ts`. Selection survives `CHANNELS.CHANGED` + refreshes. Dead/finished processes cannot be newly pinned, but an already + pinned process remains pinned if it exits. Added an "Overview panel" settings + detail item and scenarios in `tests/scenarios/14-overview/`. Unit/render tests + cover sorting/filtering, width safety, header layout, empty states, and + pin/unpin behavior. + +## Out of scope for the rewrite + +These are tracked separately and must not block completing the rewrite: + +- **Cross-session persistence** — `docs/future-persistent-manager.md`. +- **Cleanup hooks** — `docs/future-cleanup-hooks.md` and `PLAN.md` Phase 7. This + is a post-rewrite feature: optional `process start cleanup: { command, + timeoutMs }`, two-step stop (stop process then run cleanup), `process update` + cleanup mutation, and an advisory command-nudging follow-up. +- **`write` LLM tool action** — `ProcessManager.writeToStdin()` exists in `src/` + but is intentionally not exposed as a tool action in the rewrite. +- **`logs` LLM tool action** — dropped as redundant (`list`/`output` return log + file paths). +- **`debug_preview` action** — intentionally removed. diff --git a/PLAN.md b/PLAN.md index 7b37ad6..6203547 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,7 +7,7 @@ Rewrite `@aliou/pi-processes` from a single extension into a package exposing 3 2. **processes-logs** -- owns `/ps:logs` only. Logs-focused overlay for process output, search, stream filtering, and follow mode. 3. **processes-dock** -- owns `/ps:dock`, `/ps:pin`, dock widget, status widget. Positioned as a reference extension showing how to build custom process UIs using core events. -The core extension does not register `/ps`. That command is deferred until a process overview/control UI exists. If the overview UI is not loaded, there is no `/ps` command — the user still has the LLM tool, `/ps:logs`, and `/ps:settings`. +The core extension registers `/ps` as the process overview/control UI. It is a full-screen editor-replacement panel for browsing processes, killing live processes, clearing finished processes, filtering/sorting the list, previewing recent output, and pinning live processes to the dock. The rewrite preserves the intended user-facing behavior. The LLM-facing `process` tool supports `start`, `list`, `stop`, `output`, and `update`. The `update` action allows renaming processes and mutating log watches on running processes. @@ -102,9 +102,7 @@ Implemented and validated in Phase 2E: - Session shutdown disposes protocol listeners and subscriptions before killing and cleaning up the manager. Latest validation: -- `pnpm lint` passes. -- `pnpm typecheck` passes. -- `pnpm test` passes with 281 tests. +- `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm test:e2e` all pass. Phase 2G update tool action is complete. @@ -136,12 +134,14 @@ Implemented and validated in Phase 2F: - Protocol payloads remain structured and language-neutral; localized text is display-only. - Unit tests for config, build-sections, apply-setting-change, background-blocker, and i18n. -Current intentional gaps: -- No dock UI extension yet (`extensions/processes-dock/` is the next phase). +Current intentional gaps (tracked in NEXT.md): +- `/ps` overview/control panel is implemented in the core extension. +- No `clear` LLM tool action yet. The `COMMAND_CLEAR` protocol exists; the tool action is planned after `/ps`. The agent can `read` log file paths returned by `list` or `output` for full-log access meanwhile. +- `write` LLM tool action is intentionally not planned for the rewrite. `ProcessManager.writeToStdin()` exists in `src/` but is not exposed as a tool action. +- `logs` LLM tool action was intentionally dropped as redundant; `list` and `output` return log file paths. +- `debug-preview` is intentionally removed from the plan. - Keybindings are managed by Pi's built-in KeybindingsManager; not in extension config. -- `clear` and `write` tool actions are deferred. The agent can `read` log file paths returned by `list` or `output` for full-log access. - `package.json` still references `./skills/pi-processes`, but the local `skills/` directory is absent. Either restore the skill later or remove the `pi.skills`/`files` entries during cleanup. -- `debug-preview` is intentionally removed from the plan. - Agent steering guidance (promptGuidelines rewrite, skill, steering text in tool output) is deferred to Phase 5. Integrated decisions: @@ -1021,13 +1021,13 @@ Phase 3 is complete. The logs extension is registered in `package.json`, `/ps:lo Own `/ps:logs` only. The logs extension is focused on process output: selecting a process, viewing live logs, scrolling, searching, stream filtering, and follow mode. It does not own process-management controls such as kill or clear. -`/ps` is deferred until after `/ps:logs` UI tweaking is complete. It will be defined in the main process UI extension as a quick overview/control surface for the process list (for example widget or overlay, with kill/clear actions). Until that is implemented, there is no `/ps` command. The user still has the LLM `process` tool, `/ps:logs`, and `/ps:settings`. +`/ps` is implemented in the core extension as a process overview/control UI. It is not an alias for `/ps:logs`; `/ps:logs` remains logs-only. ### Design decisions - `/ps:logs` is logs-only and is registered by this extension. -- `/ps` is not an alias for `/ps:logs`. It is deferred and will be implemented later as the process overview/control UI. -- Kill and clear do not belong in `/ps:logs`; they belong in the future `/ps` overview/control UI. +- `/ps` is not an alias for `/ps:logs`; it is the overview/control UI. +- Kill and clear do not belong in `/ps:logs`; they belong in `/ps`. - Uses the log subscription protocol for live output streaming. - Uses `pi.events` exclusively to talk to the core extension. No `ProcessManager` imports. - Config values (output defaults, follow settings) are obtained via `CHANNELS.REQUEST_CONFIG`. @@ -1215,6 +1215,10 @@ Recommended rendering behavior: ## Phase 4: Dock Extension (`extensions/processes-dock/`) +### Status + +Phase 4 is complete. The dock extension is registered in `package.json`, `/ps:dock` and `/ps:pin` are wired through the core command protocol, the dock widget and status widget are driven by `extensions/processes-dock/widget/setup.ts`, and the extension consumes `CHANNELS.NOTIFICATION` log-match events. Scenario coverage lives in `tests/scenarios/13-dock/`. + ### Goal Own `/ps:dock`, `/ps:pin`, the dock widget, and the status widget. @@ -1377,7 +1381,7 @@ Update the structure section to reflect the new directory layout. - Start a process -> appears in `/ps`, dock updates, status widget updates - Process exits successfully -> notification appears, dock auto-hides (if configured), `/ps` shows exit(0) - Process fails -> notification custom message appears with exit code and triggers an agent turn -- Stop a process -> tool `stop` action or future `/ps` overview/control UI +- Stop a process -> tool `stop` action or `/ps` overview/control UI **Session lifecycle:** - Reload/new/fork -> the current extension-owned manager is shut down @@ -1550,20 +1554,26 @@ Design notes live in `docs/future-cleanup-hooks.md` until this phase is ready to ## Appendix A: Things That Change for Users -During the rewrite, the extension is intentionally restored in slices. +Phases 1 through 4 (plus Phase 3 bis) are complete. Current user-visible state: -Current user-visible state after Phase 2G: +Done: - The LLM-facing `process` tool exists with `start`, `list`, `stop`, `output`, and `update` actions. +- `/ps` overview/control panel is implemented in the core extension. It uses a local vendored header-capable panel, a fixed-height scrollable process-list content area, a bounded recent-output preview, colored statuses, filter/sort controls, clear/kill actions, and dock pin/unpin via `CHANNELS.COMMAND_PIN`. - `/ps:settings` works. -- No `/ps`, `/ps:logs`, `/ps:dock`, or `/ps:pin` yet. - -Planned final user-visible behavior: -- `/ps:logs` opens the logs-only overlay. -- `/ps` is implemented later as a process overview/control UI with kill and clear actions. -- `/ps:dock` and `/ps:pin` are in the dock extension (separate, optional). -- The dock extension is documented as a reference for building custom process UIs. - -Cross-session process persistence is deferred; see `docs/future-persistent-manager.md`. +- `/ps:logs` opens the logs-only subscription overlay with search, stream filter, follow, and notify-match highlighting. +- `/ps:dock` and `/ps:pin` are live in the dock extension (separate, optional). The dock extension is documented as a reference for building custom process UIs. +- `/ps:kill` and `/ps:clear` commands are wired through the core command protocol. + +Remaining (see NEXT.md): +- `clear` LLM tool action: planned after `/ps`. +- Agent steering guidance (promptGuidelines, skill, steering text in tool output). +- Final package/config cleanup. + +Intentionally not in the rewrite: +- `write` LLM tool action (manager API exists, not exposed). +- `logs` LLM tool action (redundant with `list`/`output`). +- `debug_preview` action (removed). +- Cross-session process persistence; see `docs/future-persistent-manager.md`. ## Appendix B: Manager Lifetime Policy diff --git a/extensions/processes/client.ts b/extensions/processes/client.ts new file mode 100644 index 0000000..7b02b47 --- /dev/null +++ b/extensions/processes/client.ts @@ -0,0 +1,168 @@ +/** + * Core-extension request/command helpers for the `/ps` overview panel. + * + * The overview panel lives in the core extension but, per NEXT.md, prefers the + * existing `pi.events` protocol channels over calling the manager directly so + * a future split-out of the panel stays cheap. These helpers are the core + * equivalent of `extensions/processes-logs/client.ts` and + * `extensions/processes-dock/client.ts`. + */ + +import type { EventBus } from "@earendil-works/pi-coding-agent"; +import { + CHANNELS, + type CommandClearPayload, + type CommandKillPayload, + type CommandPinPayload, + type CommandPinResult, + type ProcessProtocolConfig, + type RequestCombinedOutputPayload, + type RequestConfigPayload, + type RequestGetPayload, + type RequestListPayload, +} from "../../src/protocol"; +import type { KillResult, ProcessInfo } from "../../src/types"; + +export type ProcessLogLine = { type: "stdout" | "stderr"; text: string }; + +export function requestProcessList(events: EventBus): ProcessInfo[] { + let processes: ProcessInfo[] = []; + const payload: RequestListPayload = { + reply: (result) => { + processes = result; + }, + }; + events.emit(CHANNELS.REQUEST_LIST, payload); + return processes; +} + +export function requestProcess( + events: EventBus, + id: string, +): ProcessInfo | null { + let process: ProcessInfo | null = null; + const payload: RequestGetPayload = { + id, + reply: (result) => { + process = result; + }, + }; + events.emit(CHANNELS.REQUEST_GET, payload); + return process; +} + +export function requestConfig(events: EventBus): ProcessProtocolConfig { + let config: ProcessProtocolConfig | null = null; + const payload: RequestConfigPayload = { + reply: (result) => { + config = result; + }, + }; + events.emit(CHANNELS.REQUEST_CONFIG, payload); + if (!config) { + throw new Error("processes core extension did not reply to config request"); + } + return config; +} + +export function requestCombinedOutput( + events: EventBus, + id: string, + tailLines?: number, +): ProcessLogLine[] { + let lines: ProcessLogLine[] | null = null; + const payload: RequestCombinedOutputPayload = { + id, + tailLines, + reply: (result) => { + lines = result; + }, + }; + events.emit(CHANNELS.REQUEST_COMBINED_OUTPUT, payload); + return lines ?? []; +} + +export function requestKill( + events: EventBus, + id: string, + options?: { signal?: NodeJS.Signals; timeoutMs?: number }, +): KillResult { + let result: KillResult | null = null; + const payload: CommandKillPayload = { + id, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + reply: (value) => { + result = value; + }, + }; + events.emit(CHANNELS.COMMAND_KILL, payload); + return ( + result ?? { + ok: false, + reason: "error", + info: { + id, + name: "(unknown)", + pid: -1, + command: "", + cwd: "", + startTime: 0, + endTime: null, + status: "exited", + exitCode: null, + success: false, + stdoutFile: "", + stderrFile: "", + endReason: null, + signal: null, + errorMessage: "No kill handler replied", + }, + } + ); +} + +export function requestClear(events: EventBus): number { + let cleared = 0; + const payload: CommandClearPayload = { + reply: (value) => { + cleared = value; + }, + }; + events.emit(CHANNELS.COMMAND_CLEAR, payload); + return cleared; +} + +/** + * Pin a process to the dock. Resolves when the dock extension replies, or + * rejects if no dock handler responds within `timeoutMs`. + * + * The dock extension must be loaded for this to succeed. If it is not + * registered, no listener will reply and the promise rejects with a timeout. + */ +export function requestPin( + events: EventBus, + id: string | null, + timeoutMs = 200, +): Promise { + return new Promise((resolve) => { + let settled = false; + const payload: CommandPinPayload = { + id, + reply: (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }, + }; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve({ ok: false, error: "Dock extension is not available" }); + }, timeoutMs); + // The timeout needs to be unref'd so it never keeps the event loop alive. + timer.unref?.(); + events.emit(CHANNELS.COMMAND_PIN, payload); + }); +} diff --git a/extensions/processes/commands/overview.ts b/extensions/processes/commands/overview.ts new file mode 100644 index 0000000..a20559b --- /dev/null +++ b/extensions/processes/commands/overview.ts @@ -0,0 +1,93 @@ +import type { + EventBus, + ExtensionAPI, + ExtensionCommandContext, + Theme, +} from "@earendil-works/pi-coding-agent"; +import type { ProcessProtocolConfig } from "../../../src/protocol"; +import type { ProcessInfo } from "../../../src/types"; +import { requestConfig, requestProcess, requestProcessList } from "../client"; +import { OverviewComponent } from "../components/overview-component"; + +export interface OpenOverviewOptions { + events: EventBus; + registerOverlay: (overlay: { dispose: () => void }) => () => void; +} + +/** + * Register the `/ps` overview/control panel. + * + * `/ps` replaces the editor while open (non-overlay `ctx.ui.custom`). It lives + * in the core extension but talks to the manager exclusively over `pi.events` + * protocol channels, so a future split-out stays cheap. + */ +export function registerOverviewCommand( + pi: Pick, + options: OpenOverviewOptions, +): void { + pi.registerCommand("ps", { + description: "Open the managed process overview panel.", + handler: async (args: string, ctx: ExtensionCommandContext) => { + await openOverview(args, ctx, options); + }, + }); +} + +async function openOverview( + args: string, + ctx: ExtensionCommandContext, + options: OpenOverviewOptions, +): Promise { + const requestedId = args.trim().split(/\s+/, 1)[0] || undefined; + const initialProcessId = requestedId + ? requestProcess(options.events, requestedId)?.id + : undefined; + + if (requestedId && !initialProcessId) { + const message = `Process not found: ${requestedId}`; + if (ctx.hasUI) ctx.ui.notify(message, "warning"); + else console.log(message); + return; + } + + if (!ctx.hasUI) { + const processes = requestProcessList(options.events); + console.log(formatPlainProcessList(processes)); + return; + } + + let config: ProcessProtocolConfig; + try { + config = requestConfig(options.events); + } catch (error) { + ctx.ui.notify(String(error), "error"); + return; + } + + await ctx.ui.custom<"closed">((tui, theme: Theme, _keybindings, done) => { + let unregister: () => void = () => undefined; + const overlay = new OverviewComponent({ + events: options.events, + tui, + theme, + config, + initialProcessId, + onClose: () => { + unregister(); + done("closed"); + }, + }); + unregister = options.registerOverlay(overlay); + return overlay; + }); +} + +function formatPlainProcessList(processes: ProcessInfo[]): string { + if (processes.length === 0) return "No managed processes."; + return processes + .map( + (process) => + `${process.id}\t${process.name}\t${process.status}\t${process.command}`, + ) + .join("\n"); +} diff --git a/extensions/processes/components/overview-component.test.ts b/extensions/processes/components/overview-component.test.ts new file mode 100644 index 0000000..549a006 --- /dev/null +++ b/extensions/processes/components/overview-component.test.ts @@ -0,0 +1,244 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import type { ProcessInfo } from "../../../src/types"; +import { + applyOverviewView, + formatStatusShort, + type OverviewFilter, + type OverviewSort, + renderStatusDot, +} from "./overview-component"; + +function makeTheme(): Theme { + return { + fg: (color: string, text: string) => `{${color}:${text}}`, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme; +} + +function makeProcess(overrides: Partial = {}): ProcessInfo { + return { + id: "proc_1", + name: "dev", + pid: 1, + command: "pnpm dev", + cwd: "/tmp", + startTime: 1000, + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: "", + stderrFile: "", + endReason: null, + signal: null, + errorMessage: null, + ...overrides, + }; +} + +describe("applyOverviewView", () => { + const running = makeProcess({ + id: "proc_1", + name: "dev", + status: "running", + startTime: 100, + }); + const exitedOk = makeProcess({ + id: "proc_2", + name: "build", + status: "exited", + success: true, + startTime: 200, + }); + const failed = makeProcess({ + id: "proc_3", + name: "audit", + status: "exited", + success: false, + startTime: 300, + }); + const killed = makeProcess({ + id: "proc_4", + name: "watcher", + status: "killed", + success: false, + startTime: 50, + }); + + it("sorts live-first then newest start by default (status)", () => { + const result = applyOverviewView( + [exitedOk, killed, running, failed], + "status", + "all", + "", + ); + expect(result.map((p) => p.id)).toEqual([ + "proc_1", + "proc_3", + "proc_2", + "proc_4", + ]); + }); + + it("sorts by started (newest first)", () => { + const result = applyOverviewView( + [running, exitedOk, failed], + "started", + "all", + "", + ); + expect(result.map((p) => p.id)).toEqual(["proc_3", "proc_2", "proc_1"]); + }); + + it("sorts by name (alphabetical)", () => { + const result = applyOverviewView( + [running, exitedOk, failed], + "name", + "all", + "", + ); + expect(result.map((p) => p.name)).toEqual(["audit", "build", "dev"]); + }); + + it("filters to running only", () => { + const result = applyOverviewView( + [running, exitedOk, failed, killed], + "status", + "running", + "", + ); + expect(result.map((p) => p.id)).toEqual(["proc_1"]); + }); + + it("filters to finished only", () => { + const result = applyOverviewView( + [running, exitedOk, failed, killed], + "status", + "finished", + "", + ); + expect(result.map((p) => p.id)).toEqual(["proc_3", "proc_2", "proc_4"]); + }); + + it("applies a case-insensitive quick filter on name", () => { + const result = applyOverviewView( + [running, exitedOk, failed], + "status", + "all", + "BUI", + ); + expect(result.map((p) => p.name)).toEqual(["build"]); + }); + + it("returns an empty array when nothing matches the quick filter", () => { + const result = applyOverviewView( + [running, exitedOk], + "status", + "all", + "nope", + ); + expect(result).toEqual([]); + }); + + it("does not mutate the input array", () => { + const input = [running, exitedOk]; + applyOverviewView(input, "started", "all", ""); + expect(input.map((p) => p.id)).toEqual(["proc_1", "proc_2"]); + }); + + it("sort:status keeps a stable preference for live processes", () => { + const sort: OverviewSort = "status"; + const filter: OverviewFilter = "all"; + const result = applyOverviewView([exitedOk, running], sort, filter, ""); + expect(result[0]?.id).toBe("proc_1"); + }); +}); + +describe("formatStatusShort", () => { + it("returns running statuses verbatim", () => { + expect(formatStatusShort(makeProcess({ status: "running" }))).toBe( + "running", + ); + expect(formatStatusShort(makeProcess({ status: "terminating" }))).toBe( + "terminating", + ); + }); + + it("renders exited+success as 'exited'", () => { + expect( + formatStatusShort(makeProcess({ status: "exited", success: true })), + ).toBe("exited"); + }); + + it("renders exited+failure as 'failed'", () => { + expect( + formatStatusShort(makeProcess({ status: "exited", success: false })), + ).toBe("failed"); + }); + + it("renders killed as 'killed' even though success is false", () => { + expect( + formatStatusShort(makeProcess({ status: "killed", success: false })), + ).toBe("killed"); + }); +}); + +describe("renderStatusDot", () => { + const theme = makeTheme(); + + it("uses error '!' for failed non-killed processes", () => { + const dot = renderStatusDot( + makeProcess({ status: "exited", success: false }), + false, + theme, + ); + expect(dot).toBe("{error:!}"); + }); + + it("uses accent dot for a selected running process", () => { + const dot = renderStatusDot( + makeProcess({ status: "running" }), + true, + theme, + ); + expect(dot).toBe("{accent:●}"); + }); + + it("uses dim dot for an unselected running process", () => { + const dot = renderStatusDot( + makeProcess({ status: "running" }), + false, + theme, + ); + expect(dot).toBe("{dim:○}"); + }); + + it("uses success dot for exited-ok processes", () => { + const dot = renderStatusDot( + makeProcess({ status: "exited", success: true }), + false, + theme, + ); + expect(dot).toBe("{success:●}"); + }); + + it("uses warning dot for terminating processes", () => { + const dot = renderStatusDot( + makeProcess({ status: "terminating" }), + false, + theme, + ); + expect(dot).toBe("{warning:●}"); + }); + + it("uses dim square for killed processes", () => { + const dot = renderStatusDot( + makeProcess({ status: "killed", success: false }), + false, + theme, + ); + expect(dot).toBe("{dim:■}"); + }); +}); diff --git a/extensions/processes/components/overview-component.ts b/extensions/processes/components/overview-component.ts new file mode 100644 index 0000000..e94de08 --- /dev/null +++ b/extensions/processes/components/overview-component.ts @@ -0,0 +1,734 @@ +import { Stack } from "@aliou/pi-utils-ui"; +import type { EventBus, Theme } from "@earendil-works/pi-coding-agent"; +import { + type Component, + Input, + Key, + matchesKey, + type TUI, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { + CHANNELS, + type ProcessesOutputChangedPayload, + type ProcessProtocolConfig, +} from "../../../src/protocol"; +import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types"; +import { formatRuntime, truncateCmd } from "../../../src/utils/format"; +import { isRecord } from "../../../src/utils/is-record"; +import { + type ProcessLogLine, + requestClear, + requestCombinedOutput, + requestKill, + requestPin, + requestProcessList, +} from "../client"; +import { OverviewPanel } from "./overview-panel"; + +/** Sort mode for the overview list. */ +export type OverviewSort = "status" | "started" | "name"; + +/** Filter mode for the overview list. */ +export type OverviewFilter = "all" | "running" | "finished"; + +export const OVERVIEW_SORTS: OverviewSort[] = ["status", "started", "name"]; +export const OVERVIEW_FILTERS: OverviewFilter[] = [ + "all", + "running", + "finished", +]; + +interface OverviewOptions { + events: EventBus; + tui: TUI; + theme: Theme; + config: ProcessProtocolConfig; + initialProcessId?: string; + onClose: () => void; +} + +const MAX_NAME_WIDTH = 16; +const MAX_ID_WIDTH = 10; +const STATUS_WIDTH = 10; +const RUNTIME_WIDTH = 6; +const PREVIEW_LOG_PREFIX = " "; +const MAX_PREVIEW_LINES = 2000; +const PIN_REPLY_TIMEOUT_MS = 200; +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. + * + * Uses `pi.events` exclusively (no direct manager access) so it can be split + * out of the core extension later without changes. Selection survives list + * refreshes by remembering the selected process id and re-finding its index. + */ +export class OverviewComponent implements Component { + private processes: ProcessInfo[] = []; + private selectedIndex = 0; + private viewOffset = 0; + private previewLines: ProcessLogLine[] = []; + private previewOffset = 0; + private mode: Mode = "normal"; + private readonly filterInput = new Input(); + private sort: OverviewSort = "status"; + private filter: OverviewFilter = "all"; + private quickFilter = ""; + private pinUnavailable = false; + private pinnedId: string | null = null; + private hasAnyProcesses = false; + private totalProcessCount = 0; + private runningProcessCount = 0; + private readonly disposers: Array<() => void> = []; + private disposed = false; + + constructor(private readonly opts: OverviewOptions) { + this.configureFilterInput(); + this.refresh(opts.initialProcessId); + this.disposers.push( + opts.events.on(CHANNELS.CHANGED, () => this.handleChanged()), + ); + this.disposers.push( + opts.events.on(CHANNELS.OUTPUT_CHANGED, (payload) => + this.handleOutputChanged(payload), + ), + ); + } + + render(width: number): string[] { + const tooSmall = width < MIN_OVERVIEW_WIDTH; + const panel = new OverviewPanel({ + title: tooSmall ? undefined : "Processes", + headerLeft: tooSmall ? undefined : this.renderHeaderLeft(), + headerRight: tooSmall ? undefined : this.renderHeaderRight(), + body: tooSmall ? this.buildTooSmallBody(width) : this.buildBody(), + footer: tooSmall ? undefined : this.buildFooter(), + padding: 0, + borderStyle: (text) => this.opts.theme.fg("dim", text), + titleStyle: (text) => + this.opts.theme.fg("accent", this.opts.theme.bold(text)), + metaStyle: (text) => this.opts.theme.fg("dim", text), + }); + return panel.render(width); + } + + handleInput(data: string): void { + if (this.mode === "filter-typing") { + this.filterInput.handleInput?.(data); + this.requestRender(); + return; + } + + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + this.close(); + return; + } + if (data === "q" || data === "Q") { + this.close(); + return; + } + if (matchesKey(data, Key.down) || data === "j") this.moveSelection(1); + else if (matchesKey(data, Key.up) || data === "k") this.moveSelection(-1); + else if (data === "J") this.scrollPreview(1); + else if (data === "K") this.scrollPreview(-1); + else if (data === "g") { + this.previewOffset = 0; + this.requestRender(); + } else if (data === "G") { + this.previewOffset = Math.max(0, this.previewLines.length - 1); + this.requestRender(); + } else if (data === "x") this.killSelected(); + else if (data === "c") this.clearFinished(); + else if (data === "s") this.cycleSort(); + else if (data === "f") this.cycleFilter(); + else if (data === "/") this.startQuickFilter(); + else if (matchesKey(data, Key.enter)) void this.pinSelected(); + } + + invalidate(): void {} + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const dispose of this.disposers.splice(0)) dispose(); + } + + // --- refresh / state --- + + private refresh(preferredId?: string): void { + const all = requestProcessList(this.opts.events); + this.hasAnyProcesses = all.length > 0; + this.totalProcessCount = all.length; + this.runningProcessCount = all.filter((process) => + LIVE_STATUSES.has(process.status), + ).length; + this.processes = this.applyView(all); + if (this.processes.length === 0) { + this.selectedIndex = 0; + this.viewOffset = 0; + this.previewLines = []; + this.previewOffset = 0; + return; + } + const keepId = preferredId ?? this.processes[this.selectedIndex]?.id; + const nextIndex = keepId + ? this.processes.findIndex((process) => process.id === keepId) + : -1; + this.selectedIndex = + nextIndex >= 0 + ? nextIndex + : Math.min(this.selectedIndex, this.processes.length - 1); + this.clampViewOffset(); + this.refreshPreview(); + } + + private handleChanged(): void { + this.refresh(); + this.requestRender(); + } + + private handleOutputChanged(payload: unknown): void { + if (!isOutputChangedPayload(payload)) return; + const selected = this.selectedProcess(); + if (!selected || selected.id !== payload.id) return; + this.refreshPreview(); + this.requestRender(); + } + + private applyView(processes: ProcessInfo[]): ProcessInfo[] { + const filtered = this.filterByMode(processes); + const quick = this.quickFilter + ? filtered.filter((process) => + process.name.toLowerCase().includes(this.quickFilter.toLowerCase()), + ) + : filtered; + return this.sortByMode(quick); + } + + private filterByMode(processes: ProcessInfo[]): ProcessInfo[] { + if (this.filter === "running") { + return processes.filter((process) => LIVE_STATUSES.has(process.status)); + } + if (this.filter === "finished") { + return processes.filter((process) => !LIVE_STATUSES.has(process.status)); + } + return processes; + } + + private sortByMode(processes: ProcessInfo[]): ProcessInfo[] { + const copy = [...processes]; + if (this.sort === "status") { + copy.sort((a, b) => { + const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0; + const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0; + if (bLive !== aLive) return bLive - aLive; + return b.startTime - a.startTime; + }); + } else if (this.sort === "started") { + copy.sort((a, b) => b.startTime - a.startTime); + } else { + copy.sort((a, b) => a.name.localeCompare(b.name)); + } + return copy; + } + + private selectedProcess(): ProcessInfo | null { + return this.processes[this.selectedIndex] ?? null; + } + + private moveSelection(delta: number): void { + if (this.processes.length === 0) return; + this.selectedIndex = + (this.selectedIndex + delta + this.processes.length) % + this.processes.length; + this.clampViewOffset(); + this.previewOffset = 0; + this.refreshPreview(); + this.requestRender(); + } + + private clampViewOffset(): void { + const maxVisible = this.maxVisibleRows(); + if (this.selectedIndex < this.viewOffset) { + this.viewOffset = this.selectedIndex; + } else if (this.selectedIndex >= this.viewOffset + maxVisible) { + this.viewOffset = this.selectedIndex - maxVisible + 1; + } + this.viewOffset = Math.max(0, this.viewOffset); + } + + private maxVisibleRows(): number { + return Math.max(1, this.opts.config.processList.maxVisibleProcesses); + } + + private scrollPreview(delta: number): void { + this.previewOffset = Math.max( + 0, + Math.min(this.previewLines.length - 1, this.previewOffset + delta), + ); + this.requestRender(); + } + + private refreshPreview(): void { + const selected = this.selectedProcess(); + if (!selected) { + this.previewLines = []; + return; + } + const lines = requestCombinedOutput( + this.opts.events, + selected.id, + this.opts.config.output.defaultTailLines, + ); + this.previewLines = lines.slice(-MAX_PREVIEW_LINES); + this.previewOffset = Math.min( + this.previewOffset, + Math.max(0, this.previewLines.length - 1), + ); + } + + private cycleSort(): void { + this.sort = + OVERVIEW_SORTS[ + (OVERVIEW_SORTS.indexOf(this.sort) + 1) % OVERVIEW_SORTS.length + ]; + this.reapplyKeepSelection(); + } + + private cycleFilter(): void { + this.filter = + OVERVIEW_FILTERS[ + (OVERVIEW_FILTERS.indexOf(this.filter) + 1) % OVERVIEW_FILTERS.length + ]; + this.reapplyKeepSelection(); + } + + private reapplyKeepSelection(): void { + const keepId = this.selectedProcess()?.id; + const all = requestProcessList(this.opts.events); + this.hasAnyProcesses = all.length > 0; + this.totalProcessCount = all.length; + this.runningProcessCount = all.filter((process) => + LIVE_STATUSES.has(process.status), + ).length; + this.processes = this.applyView(all); + const nextIndex = keepId + ? this.processes.findIndex((process) => process.id === keepId) + : -1; + this.selectedIndex = + nextIndex >= 0 + ? nextIndex + : Math.min(this.selectedIndex, Math.max(0, this.processes.length - 1)); + this.clampViewOffset(); + this.refreshPreview(); + this.requestRender(); + } + + private killSelected(): void { + const selected = this.selectedProcess(); + if (!selected) return; + requestKill(this.opts.events, selected.id); + // CHANNELS.CHANGED re-renders on kill completion. + } + + private clearFinished(): void { + requestClear(this.opts.events); + // CHANNELS.CHANGED re-renders on clear completion. + } + + private async pinSelected(): Promise { + const selected = this.selectedProcess(); + if (!selected) return; + // Toggle: if the selected process is already pinned, unpin it. + const isPinned = this.pinnedId === selected.id; + if (!isPinned && !LIVE_STATUSES.has(selected.status)) { + this.requestRender(); + return; + } + const result = await requestPin( + this.opts.events, + isPinned ? null : selected.id, + PIN_REPLY_TIMEOUT_MS, + ); + this.pinUnavailable = !result.ok; + if (result.ok) { + this.pinnedId = isPinned ? null : selected.id; + } + this.requestRender(); + } + + private configureFilterInput(): void { + this.filterInput.onSubmit = (query) => { + this.quickFilter = query.trim(); + this.mode = "normal"; + this.reapplyKeepSelection(); + }; + this.filterInput.onEscape = () => { + this.quickFilter = ""; + this.mode = "normal"; + this.requestRender(); + }; + } + + private startQuickFilter(): void { + this.filterInput.setValue(this.quickFilter); + this.mode = "filter-typing"; + this.requestRender(); + } + + private close(): void { + this.dispose(); + this.opts.onClose(); + } + + private requestRender(): void { + this.opts.tui.requestRender(); + } + + // --- rendering --- + + private buildBody(): Component { + if (this.processes.length === 0) { + return new LinesComponent((w) => this.renderEmptyState(w)); + } + + const body = new Stack({ gap: 0 }); + body.addChild(new LinesComponent((w) => this.renderListRows(w))); + body.addChild(new LineComponent((w) => this.renderRule(w))); + body.addChild(new LinesComponent((w) => this.renderPreview(w))); + return body; + } + + private buildTooSmallBody(width: number): Component { + const t = this.opts.theme; + return new LinesComponent(() => [ + t.fg("warning", "Terminal too small for /ps."), + t.fg("dim", `Need ${MIN_OVERVIEW_WIDTH} columns, have ${width}.`), + "", + t.fg("dim", "Resize and reopen /ps."), + ]); + } + + private renderEmptyState(width: number): string[] { + const t = this.opts.theme; + const filtering = this.hasAnyProcesses; + const title = t.fg("muted", "No managed processes"); + const description = t.fg( + "dim", + filtering + ? "No processes match the current filter. Press f or / to change it." + : "Start one with the process tool, then reopen /ps", + ); + const titleW = visibleWidth(title); + const descW = visibleWidth(description); + + // Fixed small block: a couple of blank lines, title, blank, description. + const lines: string[] = ["", "", "", ""]; + + const titleRow = 1; + const descRow = 3; + const titlePad = Math.max(0, Math.floor((width - titleW) / 2)); + const descPad = Math.max(0, Math.floor((width - descW) / 2)); + lines[titleRow] = truncateToWidth( + `${" ".repeat(titlePad)}${title}`, + width, + "", + true, + ); + lines[descRow] = truncateToWidth( + `${" ".repeat(descPad)}${description}`, + width, + "", + true, + ); + return lines; + } + + private renderPinHint(): string { + const t = this.opts.theme; + const dim = (value: string) => t.fg("dim", value); + const accent = (value: string) => t.fg("accent", value); + if (this.pinUnavailable) return dim("pin (dock not loaded)"); + const selected = this.selectedProcess(); + if (selected && this.pinnedId === selected.id) { + return `${accent("unpin")}`; + } + if (selected && !LIVE_STATUSES.has(selected.status)) { + return dim("pin running only"); + } + return accent("pin"); + } + + private renderHeaderLeft(): string { + const parts = [ + `${this.runningProcessCount}/${this.totalProcessCount} running`, + ]; + if (this.sort !== "status") parts.push(`sort: ${this.sort}`); + if (this.filter !== "all" || this.quickFilter) { + const filter = `${this.filter}${this.quickFilter ? ` "${this.quickFilter}"` : ""}`; + parts.push(`filter: ${filter}`); + } + return parts.join(" - "); + } + + private renderHeaderRight(): string { + const hiddenAbove = this.viewOffset; + const hiddenBelow = Math.max( + 0, + this.processes.length - (this.viewOffset + this.maxVisibleRows()), + ); + return [ + hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : "", + hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : "", + ] + .filter(Boolean) + .join(" - "); + } + + private renderRule(width: number): string { + const t = this.opts.theme; + return t.fg("dim", "─".repeat(Math.max(0, width))); + } + + private renderListRows(width: number): string[] { + const maxVisible = this.maxVisibleRows(); + const start = this.viewOffset; + const rows: string[] = []; + for (let offset = 0; offset < maxVisible; offset++) { + const index = start + offset; + const process = this.processes[index]; + rows.push( + process + ? this.renderRow(process, index === this.selectedIndex, width) + : "", + ); + } + return rows; + } + + private renderRow( + process: ProcessInfo, + selected: boolean, + width: number, + ): string { + const t = this.opts.theme; + const name = truncateToWidth(process.name, MAX_NAME_WIDTH, "", true); + const id = truncateToWidth(process.id, MAX_ID_WIDTH, "", true); + const status = truncateToWidth( + formatColoredStatusShort(process, t), + STATUS_WIDTH, + "", + true, + ); + const runtime = truncateToWidth( + formatRuntime(process.startTime, process.endTime), + RUNTIME_WIDTH, + "", + true, + ); + + const dim = (value: string) => t.fg("dim", value); + const sep = dim(" "); + + // Leading marker: ◆ for the pinned process, a space otherwise, so every + // row aligns with no offset when nothing is pinned. + const marker = this.pinnedId === process.id ? t.fg("accent", "◆") : " "; + const left = `${marker} ${name}${sep}${dim(id)}${sep}${status}${sep}${runtime}`; + const remaining = Math.max(0, width - visibleWidth(left) - 2); + const command = dim(truncateCmd(process.command, remaining)); + const line = `${left}${sep}${command}`; + + if (selected) { + const padded = truncateToWidth(line, width, "", true); + const pad = Math.max(0, width - visibleWidth(padded)); + return t.bg("selectedBg", `${padded}${" ".repeat(pad)}`); + } + return truncateToWidth(line, width, "", true); + } + + private renderPreview(width: number): string[] { + const selected = this.selectedProcess(); + if (!selected) return []; + const t = this.opts.theme; + const dim = (value: string) => t.fg("dim", value); + const accent = (value: string) => t.fg("accent", value); + + const header = `${dim(">")} ${accent(truncateCmd(selected.command, Math.max(1, width - 2)))}`; + const body: string[] = [header]; + const available = this.previewHeight(); + const total = this.previewLines.length; + const start = this.previewOffset; + const slice = this.previewLines.slice(start, start + available); + for (const line of slice) { + body.push( + truncateToWidth( + `${dim(PREVIEW_LOG_PREFIX)}${line.text}`, + width, + "", + true, + ), + ); + } + while (body.length < available + 1) body.push(""); + if (total > available) { + body.push( + truncateToWidth( + dim( + ` ${start + 1}-${Math.min(start + available, total)} of ${total} (J/K scroll)`, + ), + width, + "", + true, + ), + ); + } + return body; + } + + private previewHeight(): number { + // Fixed, bounded preview size. We deliberately do NOT size from the + // terminal height — the overview is a compact panel, not a full-screen + // log viewer (use /ps:logs for that). + return PREVIEW_HEIGHT; + } + + private buildFooter(): Component { + return new LineComponent((width) => this.renderFooterLine(width)); + } + + private renderFooterLine(width: number): string { + const t = this.opts.theme; + const dim = (value: string) => t.fg("dim", value); + + if (this.mode === "filter-typing") { + const rendered = this.filterInput.render(60)[0] ?? ""; + return truncateToWidth( + `${dim("/")}${rendered} ${dim("enter")} apply ${dim("esc")} cancel`, + width, + "", + true, + ); + } + + const keys = [ + `${dim("j/k")} move`, + `${dim("J/K")} scroll`, + `${dim("enter")} ${this.renderPinHint()}`, + `${dim("x")} kill`, + `${dim("c")} clear`, + `${dim("s")} sort`, + `${dim("f")} filter`, + `${dim("/")} find`, + `${dim("q")} close`, + ]; + return truncateToWidth(keys.join(" "), width, "", true); + } +} + +// --- 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 function formatStatusShort(process: ProcessInfo): string { + if (LIVE_STATUSES.has(process.status)) return process.status; + if (process.status === "exited") { + return process.success === false ? "failed" : "exited"; + } + return process.status; +} + +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"; +} + +/** Sort + filter view used by the overview. Pure, unit-testable. */ +export function applyOverviewView( + processes: ProcessInfo[], + sort: OverviewSort, + filter: OverviewFilter, + quickFilter: string, +): ProcessInfo[] { + const filtered = + filter === "running" + ? processes.filter((process) => LIVE_STATUSES.has(process.status)) + : filter === "finished" + ? processes.filter((process) => !LIVE_STATUSES.has(process.status)) + : processes; + const quick = quickFilter + ? filtered.filter((process) => + process.name.toLowerCase().includes(quickFilter.toLowerCase()), + ) + : filtered; + const copy = [...quick]; + if (sort === "status") { + copy.sort((a, b) => { + const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0; + const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0; + if (bLive !== aLive) return bLive - aLive; + return b.startTime - a.startTime; + }); + } else if (sort === "started") { + copy.sort((a, b) => b.startTime - a.startTime); + } else { + copy.sort((a, b) => a.name.localeCompare(b.name)); + } + return copy; +} + +// --- payload guards --- + +function isOutputChangedPayload( + payload: unknown, +): payload is ProcessesOutputChangedPayload { + return isRecord(payload) && typeof payload.id === "string"; +} diff --git a/extensions/processes/components/overview-panel.ts b/extensions/processes/components/overview-panel.ts new file mode 100644 index 0000000..94ede68 --- /dev/null +++ b/extensions/processes/components/overview-panel.ts @@ -0,0 +1,177 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +export interface OverviewPanelOptions { + title?: string; + headerLeft?: string; + headerRight?: string; + body: Component; + footer?: Component; + borderStyle?: (text: string) => string; + titleStyle?: (text: string) => string; + metaStyle?: (text: string) => string; + padding?: number; +} + +const BORDER = { + tl: "╭", + tr: "╮", + bl: "╰", + br: "╯", + left: "│", + right: "│", + footSep: "├", + footEnd: "┤", + h: "─", +}; + +/** + * Local vendored variant of @aliou/pi-utils-ui Panel for /ps. + * + * The upstream Panel only supports a centered title. The overview needs + * left/center/right header slots. Keep this small and local so it can be + * upstreamed later without changing the rest of the /ps component. + */ +export class OverviewPanel implements Component { + private readonly title: string; + private readonly headerLeft: string; + private readonly headerRight: string; + private readonly body: Component; + private readonly footer?: Component; + private readonly borderStyle: (text: string) => string; + private readonly titleStyle: (text: string) => string; + private readonly metaStyle: (text: string) => string; + private readonly padding: number; + + constructor(options: OverviewPanelOptions) { + this.title = options.title ?? ""; + this.headerLeft = options.headerLeft ?? ""; + this.headerRight = options.headerRight ?? ""; + this.body = options.body; + this.footer = options.footer; + this.borderStyle = options.borderStyle ?? ((text) => text); + this.titleStyle = options.titleStyle ?? ((text) => text); + this.metaStyle = options.metaStyle ?? ((text) => text); + this.padding = options.padding ?? 1; + } + + render(width: number): string[] { + const lines: string[] = []; + const innerWidth = Math.max(0, width - 2); + const contentWidth = Math.max(0, innerWidth - this.padding * 2); + + lines.push(this.renderHeader(innerWidth)); + + for (let i = 0; i < this.padding; i++) { + lines.push(this.borderLine(innerWidth)); + } + + for (const line of this.body.render(contentWidth)) { + lines.push(this.contentLine(innerWidth, line)); + } + + for (let i = 0; i < this.padding; i++) { + lines.push(this.borderLine(innerWidth)); + } + + if (this.footer) { + lines.push( + this.borderStyle(BORDER.footSep) + + this.borderStyle(BORDER.h.repeat(innerWidth)) + + this.borderStyle(BORDER.footEnd), + ); + for (const line of this.footer.render(contentWidth)) { + lines.push(this.contentLine(innerWidth, line)); + } + } + + lines.push( + this.borderStyle(BORDER.bl) + + this.borderStyle(BORDER.h.repeat(innerWidth)) + + this.borderStyle(BORDER.br), + ); + + return lines; + } + + invalidate(): void { + this.body.invalidate(); + this.footer?.invalidate(); + } + + private renderHeader(innerWidth: number): string { + if (innerWidth <= 0) { + return this.borderStyle(BORDER.tl) + this.borderStyle(BORDER.tr); + } + + const leftRaw = this.headerLeft ? ` ${this.headerLeft} ` : ""; + const rightRaw = this.headerRight ? ` ${this.headerRight} ` : ""; + const centerRaw = this.title ? ` ${this.title} ` : ""; + + const center = this.titleStyle(truncateToWidth(centerRaw, innerWidth, "")); + const centerWidth = visibleWidth(center); + const centerStart = Math.max(0, Math.floor((innerWidth - centerWidth) / 2)); + + const leftBudget = centerStart; + const left = this.metaStyle(truncateToWidth(leftRaw, leftBudget, "")); + + const centerEnd = centerStart + centerWidth; + const rightBudget = Math.max(0, innerWidth - centerEnd); + const right = this.metaStyle(truncateToWidth(rightRaw, rightBudget, "")); + const rightWidth = visibleWidth(right); + + let output = ""; + let cursor = 0; + const appendRuleTo = (target: number) => { + if (target <= cursor) return; + output += this.borderStyle(BORDER.h.repeat(target - cursor)); + cursor = target; + }; + const appendSegment = (segment: string) => { + output += segment; + cursor += visibleWidth(segment); + }; + + appendSegment(left); + appendRuleTo(centerStart); + appendSegment(center); + if (rightWidth > 0) { + const rightStart = Math.max(0, innerWidth - rightWidth); + if (rightStart >= cursor) { + appendRuleTo(rightStart); + appendSegment(right); + } + } + appendRuleTo(innerWidth); + + return ( + this.borderStyle(BORDER.tl) + + truncateToWidth(output, innerWidth, "", true) + + this.borderStyle(BORDER.tr) + ); + } + + private borderLine(innerWidth: number): string { + return ( + this.borderStyle(BORDER.left) + + " ".repeat(innerWidth) + + this.borderStyle(BORDER.right) + ); + } + + private contentLine(innerWidth: number, content: string): string { + const padded = + " ".repeat(this.padding) + + truncateToWidth( + content, + Math.max(0, innerWidth - this.padding * 2), + "", + true, + ) + + " ".repeat(this.padding); + const inner = truncateToWidth(padded, innerWidth, "", true); + return ( + this.borderStyle(BORDER.left) + inner + this.borderStyle(BORDER.right) + ); + } +} diff --git a/extensions/processes/components/overview-render.test.ts b/extensions/processes/components/overview-render.test.ts new file mode 100644 index 0000000..a9efd19 --- /dev/null +++ b/extensions/processes/components/overview-render.test.ts @@ -0,0 +1,421 @@ +import { EmptyState, Panel } from "@aliou/pi-utils-ui"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { CHANNELS } from "../../../src/protocol"; +import type { ProcessInfo } from "../../../src/types"; +import { OverviewComponent } from "./overview-component"; + +const theme = { + fg: (_c: string, t: string) => t, + bg: (_c: string, t: string) => t, + bold: (t: string) => t, +} as unknown; + +/** Strip ANSI escape sequences without a literal ESC control char in source. */ +const ESC = String.fromCharCode(27); +const ANSI = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); +function stripAnsi(line: string): string { + return line.replace(ANSI, ""); +} + +function makeConfig() { + return { + execution: { shellPath: undefined }, + interception: { blockBackgroundCommands: true }, + processList: { maxPreviewLines: 24, maxVisibleProcesses: 12 }, + output: { defaultTailLines: 100, maxOutputLines: 2000 }, + follow: { enabledByDefault: true, autoHideOnFinish: false }, + widget: { dockDefaultState: "closed" as const, dockHeight: 12 }, + }; +} + +function makeProcess(overrides: Partial = {}): ProcessInfo { + return { + id: "proc_1", + name: "dev", + pid: 1, + command: "pnpm dev", + cwd: "/tmp", + startTime: 1000, + endTime: null, + status: "running", + exitCode: null, + success: null, + stdoutFile: "", + stderrFile: "", + endReason: null, + signal: null, + errorMessage: null, + ...overrides, + }; +} + +/** EventBus fake that replies synchronously to request/command channels. */ +function makeEvents(processes: ProcessInfo[] = []) { + const listeners: Record void>> = {}; + const pinCalls: (string | null)[] = []; + return { + on: vi.fn((channel: string, cb: (payload: never) => void) => { + let list = listeners[channel]; + if (!list) { + list = []; + listeners[channel] = list; + } + list.push(cb); + return () => { + listeners[channel] = list.filter((l) => l !== cb); + }; + }), + emit: vi.fn((channel: string, payload: never) => { + if (channel === CHANNELS.REQUEST_LIST) { + (payload as { reply: (p: ProcessInfo[]) => void }).reply(processes); + return; + } + if (channel === CHANNELS.REQUEST_COMBINED_OUTPUT) { + ( + payload as { + reply: (lines: { type: "stdout"; text: string }[]) => void; + } + ).reply([ + { type: "stdout", text: "line one" }, + { type: "stdout", text: "line two" }, + ]); + return; + } + if (channel === CHANNELS.COMMAND_PIN) { + const p = payload as { + id: string | null; + reply: (r: { ok: true }) => void; + }; + pinCalls.push(p.id); + p.reply({ ok: true }); + return; + } + }), + pinCalls, + }; +} + +describe("overview panel render width safety", () => { + it("never exceeds terminal width across a range of widths (empty state)", () => { + const events = makeEvents(); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + for (const w of [112, 100, 80, 60, 45, 40, 30, 20, 10, 5]) { + const lines = component.render(w); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(w); + } + } + }); + + it("never exceeds terminal width with populated rows + preview", () => { + const processes = [ + makeProcess({ + id: "proc_1", + name: "dev", + command: "pnpm dev", + status: "running", + startTime: 100, + }), + makeProcess({ + id: "proc_2", + name: "a-very-long-process-name-that-exceeds", + command: "node server.js --port 3000 --host 0.0.0.0 --verbose", + status: "exited", + success: true, + startTime: 50, + endTime: 80, + }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + for (const w of [112, 100, 80, 60, 45, 40, 30, 20, 10, 5]) { + const lines = component.render(w); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(w); + } + } + }); + + it("empty state panel via Panel never exceeds width", () => { + const empty = new EmptyState({ + title: "No managed processes", + description: "Start one with the process tool, then reopen /ps", + titleStyle: (t: string) => t, + descriptionStyle: (t: string) => t, + padding: 2, + }); + const panel = new Panel({ + title: undefined, + body: empty, + border: "round", + padding: 0, + borderStyle: (t: string) => t, + titleStyle: (t: string) => t, + }); + for (const w of [112, 80, 40, 20, 10]) { + const lines = panel.render(w); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(w); + } + } + }); + + it("empty state title and description are horizontally centered", () => { + const events = makeEvents(); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + const width = 112; + const contentWidth = width - 2; + const lines = component.render(width); + const plain = lines.map(stripAnsi); + + const title = "No managed processes"; + const description = "Start one with the process tool, then reopen /ps"; + const titleIdx = plain.findIndex((l) => l.includes(title)); + const descIdx = plain.findIndex((l) => l.includes(description)); + expect(titleIdx).toBeGreaterThanOrEqual(0); + expect(descIdx).toBeGreaterThan(titleIdx); + + const titleStart = plain[titleIdx].indexOf(title); + const descStart = plain[descIdx].indexOf(description); + // Centered => border(1) + floor((contentWidth - textWidth) / 2). + expect(titleStart).toBe(1 + Math.floor((contentWidth - title.length) / 2)); + expect(descStart).toBe( + 1 + Math.floor((contentWidth - description.length) / 2), + ); + }); + + it("empty state stays centered when theme.fg emits ANSI escape codes", () => { + // A theme whose fg wraps text in ANSI codes. visibleWidth() must ignore + // the escape codes; .length-based centering (the EmptyState bug) would + // shift content left. + const ansiTheme = { + fg: (_c: string, t: string) => `\x1b[38;5;245m${t}\x1b[0m`, + bg: (_c: string, t: string) => t, + bold: (t: string) => `\x1b[1m${t}\x1b[0m`, + } as unknown; + const events = makeEvents(); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: ansiTheme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + const width = 112; + const contentWidth = width - 2; + const lines = component.render(width); + // Strip ANSI to inspect visible positions reliably (slicing the raw string + // would mangle escape codes). + const plain = lines.map(stripAnsi); + const title = "No managed processes"; + const description = "Start one with the process tool, then reopen /ps"; + const titleIdx = plain.findIndex((l) => l.includes(title)); + const descIdx = plain.findIndex((l) => l.includes(description)); + expect(titleIdx).toBeGreaterThanOrEqual(0); + expect(descIdx).toBeGreaterThan(titleIdx); + + const titleStart = plain[titleIdx].indexOf(title); + const descStart = plain[descIdx].indexOf(description); + // Panel adds a 1-char border on each side, so the body content starts at + // col 1. Centered => border(1) + floor((contentWidth - textWidth) / 2). + expect(titleStart).toBe(1 + Math.floor((contentWidth - title.length) / 2)); + expect(descStart).toBe( + 1 + Math.floor((contentWidth - description.length) / 2), + ); + }); + + it("empty state mentions changing the filter when processes exist but are filtered out", () => { + const processes = [ + makeProcess({ id: "proc_1", name: "dev", status: "running" }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + // Switch filter to "finished" so the running process is hidden. + component.handleInput("f"); + component.handleInput("f"); + const lines = component.render(112); + const joined = lines.join("\n"); + expect(joined).toContain("No managed processes"); + expect(joined).toContain("No processes match the current filter"); + expect(joined).not.toContain("Start one with the process tool"); + }); + + it("enter pins the selected process, then enter again unpins it (toggle)", async () => { + const processes = [ + makeProcess({ id: "proc_1", name: "dev", status: "running" }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + + // First enter: pin proc_1. + component.handleInput("\r"); + await Promise.resolve(); + await Promise.resolve(); + expect(events.pinCalls).toEqual(["proc_1"]); + // Footer should now offer "unpin". + let footer = component.render(112).join("\n"); + expect(footer).toContain("unpin"); + + // Second enter: unpin (id: null). + component.handleInput("\r"); + await Promise.resolve(); + await Promise.resolve(); + expect(events.pinCalls).toEqual(["proc_1", null]); + footer = component.render(112).join("\n"); + expect(footer).toContain("pin"); + expect(footer).not.toContain("unpin"); + }); + + it("the pinned process row shows a pinned marker", async () => { + const processes = [ + makeProcess({ id: "proc_1", name: "dev", status: "running" }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + + expect(events.pinCalls).toEqual([]); + component.handleInput("\r"); + await Promise.resolve(); + await Promise.resolve(); + expect(events.pinCalls).toEqual(["proc_1"]); + // The pinned row should render the marker. + const lines = component.render(112); + const body = lines.map(stripAnsi); + const row = body.find((l) => l.includes("proc_1")); + expect(row).toBeTruthy(); + expect(row).toContain("◆"); + }); + + it("does not pin finished processes", async () => { + const processes = [ + makeProcess({ + id: "proc_1", + name: "done", + status: "exited", + success: true, + endTime: 200, + }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + + component.handleInput("\r"); + await Promise.resolve(); + await Promise.resolve(); + expect(events.pinCalls).toEqual([]); + expect(component.render(112).join("\n")).toContain("pin running only"); + }); + + it("renders left meta, centered title, and right scroll indicators in the panel header", () => { + const processes = Array.from({ length: 16 }, (_, index) => + makeProcess({ + id: `proc_${index + 1}`, + name: `proc-${index + 1}`, + status: "running", + startTime: 100 - index, + }), + ); + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: 112 }, + } as unknown; + const component = new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig() as never, + onClose: () => {}, + }); + + const header = stripAnsi(component.render(112)[0] ?? ""); + expect(header.startsWith("╭")).toBe(true); + expect(header.endsWith("╮")).toBe(true); + expect(header).toContain("16/16 running"); + expect(header).toContain("Processes"); + expect(header).toContain("↓ 4 more"); + + const titleStart = header.indexOf("Processes"); + const center = Math.floor(header.length / 2); + expect(Math.abs(titleStart + "Processes".length / 2 - center)).toBeLessThan( + 2, + ); + expect(header.slice(0, titleStart)).toContain("─"); + expect(header.slice(titleStart + "Processes".length)).toContain("─"); + }); +}); diff --git a/extensions/processes/config/defaults.ts b/extensions/processes/config/defaults.ts index ce2857f..c7c0d67 100644 --- a/extensions/processes/config/defaults.ts +++ b/extensions/processes/config/defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_CONFIG: ProcessProtocolConfig = { }, processList: { maxPreviewLines: 24, + maxVisibleProcesses: 12, }, output: { defaultTailLines: 100, diff --git a/extensions/processes/config/types.ts b/extensions/processes/config/types.ts index 69650d6..f8cfaae 100644 --- a/extensions/processes/config/types.ts +++ b/extensions/processes/config/types.ts @@ -17,6 +17,7 @@ export interface InterceptionConfig { export interface ProcessListConfig { maxPreviewLines?: number; + maxVisibleProcesses?: number; } export interface OutputConfig { diff --git a/extensions/processes/hooks/cleanup.ts b/extensions/processes/hooks/cleanup.ts index 883c75f..74b3a83 100644 --- a/extensions/processes/hooks/cleanup.ts +++ b/extensions/processes/hooks/cleanup.ts @@ -14,6 +14,7 @@ interface CleanupHookDeps { notifications: NotificationRegistry; notificationService: NotificationService; disposers?: Disposer[]; + disposeOverlays?: () => void; } export function registerCleanupHook( @@ -26,6 +27,10 @@ export function registerCleanupHook( if (shuttingDown) return; shuttingDown = true; + // Close any open overview panels first so they stop listening to events + // before the manager and protocol handlers are torn down. + deps.disposeOverlays?.(); + for (const dispose of deps.disposers ?? []) { dispose(); } diff --git a/extensions/processes/index.ts b/extensions/processes/index.ts index 92a5013..77bcc3d 100644 --- a/extensions/processes/index.ts +++ b/extensions/processes/index.ts @@ -1,5 +1,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getManager } from "../../src/get-manager"; +import { registerOverviewCommand } from "./commands/overview"; import { configLoader } from "./config"; import { registerCommandHandlers } from "./handlers/commands"; import { registerNotificationDelivery } from "./handlers/notifications"; @@ -42,6 +43,12 @@ export default async function processesExtension( const getConfig = () => configLoader.getConfig(); + const openOverlays = new Set<{ dispose: () => void }>(); + const registerOverlay = (overlay: { dispose: () => void }) => { + openOverlays.add(overlay); + return () => openOverlays.delete(overlay); + }; + const disposers = [ registerEventBridge(pi.events, manager), registerRequestHandlers(pi.events, manager, getConfig), @@ -58,11 +65,16 @@ export default async function processesExtension( registerProcessNotificationRenderer(pi); registerProcessTool(pi, manager, notifications); registerProcessSettings(pi); + registerOverviewCommand(pi, { events: pi.events, registerOverlay }); registerCleanupHook(pi, { manager, notifications, notificationService, disposers, + disposeOverlays: () => { + for (const overlay of [...openOverlays]) overlay.dispose(); + openOverlays.clear(); + }, }); } diff --git a/extensions/processes/settings/build-sections.ts b/extensions/processes/settings/build-sections.ts index 7e2c49a..ac83e26 100644 --- a/extensions/processes/settings/build-sections.ts +++ b/extensions/processes/settings/build-sections.ts @@ -46,6 +46,7 @@ export function buildSections( const logsSection: SettingsSection = { label: "Interfaces", items: [ + buildOverviewDetailItem(scopedConfig, resolved, ctx), buildLogsDetailItem(scopedConfig, resolved, ctx), buildDockDetailItem(scopedConfig, resolved, ctx), ], @@ -112,6 +113,61 @@ function buildShellPathItem( }; } +function buildOverviewDetailItem( + scopedConfig: ProcessConfig, + resolved: ProcessProtocolConfig, + ctx: BuildSectionsContext, +): SettingItem { + const maxVisibleProcesses = + scopedConfig.processList?.maxVisibleProcesses ?? + resolved.processList.maxVisibleProcesses; + + return { + id: "overview.details", + label: "Overview panel", + currentValue: `${maxVisibleProcesses} rows`, + description: + "Open focused settings for the /ps overview panel visible row count.", + submenu: (_current, done) => { + const current = scopedConfig; + let nextMaxVisible = String(maxVisibleProcesses); + + const syncDraft = () => { + const updated: ProcessConfig = { + ...current, + processList: { + ...current.processList, + maxVisibleProcesses: parsePositiveInt(nextMaxVisible), + }, + }; + ctx.setDraft(updated); + }; + + return new SettingsDetailEditor({ + title: "Overview panel", + theme: ctx.theme, + fields: [ + { + id: "overview.maxVisibleProcesses", + type: "text", + label: "Visible rows", + description: + "Maximum process rows shown at once in the /ps overview. The list scrolls past this.", + getValue: () => nextMaxVisible, + setValue: (value) => { + nextMaxVisible = value; + syncDraft(); + }, + validate: positiveIntegerError, + }, + ], + getDoneSummary: () => `${parsePositiveInt(nextMaxVisible)} rows`, + onDone: (summary) => done(summary), + }); + }, + }; +} + function buildLogsDetailItem( scopedConfig: ProcessConfig, resolved: ProcessProtocolConfig, diff --git a/src/protocol/requests.ts b/src/protocol/requests.ts index 3b5b380..3231460 100644 --- a/src/protocol/requests.ts +++ b/src/protocol/requests.ts @@ -53,6 +53,7 @@ export interface ProcessProtocolConfig { }; processList: { maxPreviewLines: number; + maxVisibleProcesses: number; }; output: { defaultTailLines: number; diff --git a/tests/scenarios/14-overview/01-open-select-sort-filter.md b/tests/scenarios/14-overview/01-open-select-sort-filter.md new file mode 100644 index 0000000..094038b --- /dev/null +++ b/tests/scenarios/14-overview/01-open-select-sort-filter.md @@ -0,0 +1,19 @@ +Start three background processes with the process tool: + +1. Name "overview-dev" and run `bash ./tests/e2e/scripts/continuous-output.sh`. +2. Name "overview-mixed" and run `bash ./tests/e2e/scripts/mixed-output.sh`. +3. Name "overview-fail" and run a short command that prints "overview failure" and exits with code 1. + +Do not stop the running processes. After all starts complete, list processes and include their ids in your response. + +After the agent responds, I will manually test: + +1. `/ps` opens the overview panel replacing the editor. +2. All three processes appear with distinct status dots (running, running, failed). +3. `j/k` moves the selection and highlights the selected row. +4. The preview pane shows recent output for the selected process. +5. `J/K` scrolls the preview pane. +6. `s` cycles the sort and updates the header chips. +7. `f` cycles the filter between all, running, and finished. +8. `/` opens a quick filter; typing a name fragment narrows the list. +9. `q` closes the panel and restores the editor. diff --git a/tests/scenarios/14-overview/02-kill-clear.md b/tests/scenarios/14-overview/02-kill-clear.md new file mode 100644 index 0000000..1d72e65 --- /dev/null +++ b/tests/scenarios/14-overview/02-kill-clear.md @@ -0,0 +1,14 @@ +Start two background processes with the process tool: + +1. Name "overview-kill" and run `bash ./tests/e2e/scripts/continuous-output.sh`. +2. Name "overview-done" and run `bash ./tests/e2e/scripts/exited-task.sh` (a short command that exits successfully). + +Do not stop them. After the agent starts both, list processes and include their ids in your response. + +After the agent responds, I will manually test: + +1. `/ps` opens the overview panel. +2. `j/k` selects the finished process; `x` on the running process kills it and the list updates. +3. `c` clears the finished process and the list updates immediately. +4. After clearing, the empty-state appears when no processes remain. +5. `q` closes the panel. diff --git a/tests/scenarios/14-overview/03-pin-to-dock.md b/tests/scenarios/14-overview/03-pin-to-dock.md new file mode 100644 index 0000000..b0fb596 --- /dev/null +++ b/tests/scenarios/14-overview/03-pin-to-dock.md @@ -0,0 +1,18 @@ +Start one background process with the process tool: + +1. Name "overview-pin" and run `bash ./tests/e2e/scripts/continuous-output.sh`. + +Do not stop it. After the process starts, list it and include its id in your response. + +After the agent responds, I will manually test (with the dock extension loaded): + +1. `/ps` opens the overview panel. +2. `j/k` selects the "overview-pin" process. +3. `enter` pins it to the dock; the dock appears above the editor expanded on the pinned process. +4. The dock shows live output for the pinned process. +5. `q` closes the overview panel; the dock stays pinned. + +Then, with the dock extension NOT loaded: + +1. `/ps` opens the overview panel. +2. `enter` on a process shows `pin (dock not loaded)` in the footer and does not pin. diff --git a/tests/scenarios/14-overview/04-selection-survival.md b/tests/scenarios/14-overview/04-selection-survival.md new file mode 100644 index 0000000..5612d6e --- /dev/null +++ b/tests/scenarios/14-overview/04-selection-survival.md @@ -0,0 +1,14 @@ +Start one background process with the process tool: + +1. Name "overview-survive" and run `bash ./tests/e2e/scripts/continuous-output.sh`. + +Do not stop it. After the process starts, list it and include its id in your response. + +After the agent responds, I will manually test: + +1. `/ps` opens the overview panel and the single process is selected. +2. Start a second process by asking the agent to start another named "overview-second" running `bash ./tests/e2e/scripts/numbered-lines.sh`. +3. The overview panel updates live (via CHANNELS.CHANGED) without resetting selection to the top. +4. The originally selected process stays selected. +5. Stop the second process by asking the agent. The overview updates and selection is preserved on the still-running process. +6. `q` closes the panel. diff --git a/tests/scenarios/14-overview/readme.md b/tests/scenarios/14-overview/readme.md new file mode 100644 index 0000000..6cfc3c8 --- /dev/null +++ b/tests/scenarios/14-overview/readme.md @@ -0,0 +1,31 @@ +# Overview panel + +These scenarios verify the `/ps` overview/control panel from the core processes extension. + +## Setup + +Load the local extension in Pi. Run each scenario prompt from the project root so the script paths resolve correctly. + +Process start is LLM-only. Send the scenario prompt to the agent, then use the listed manual UI steps after the agent starts the processes. + +## Expected result + +The agent starts managed processes with the `process` tool. The user opens `/ps` and gets a full-screen overview replacing the editor: a selectable list of all processes with status dots, a command preview, a recent-output preview pane, and inline actions. The panel talks to the manager exclusively over `pi.events` protocol channels (`REQUEST_LIST`, `REQUEST_COMBINED_OUTPUT`, `COMMAND_KILL`, `COMMAND_CLEAR`, `COMMAND_PIN`), so it imports no manager code. + +## Manual checklist + +- `/ps` opens a full-screen panel (round border, "Processes" title) replacing the editor. +- Each row shows status dot, name, id, status, runtime, and command preview. +- `j/k` or arrows move the selection; the selected row is highlighted and stays visible when it scrolls off-screen. +- `J/K` scroll the recent-output preview pane; `g/G` jump to top/bottom. +- `s` cycles sort (status -> started -> name); the header chips show the current sort. +- `f` cycles filter (all -> running -> finished); the header chips show the current filter. +- `/` opens a quick name filter; `enter` applies, `esc` clears. +- `enter` pins the selected process to the dock (if the dock extension is loaded) and expands it. +- `enter` on a process when the dock is not loaded shows `pin (dock not loaded)` in the footer. +- `x` kills the selected process; the list updates via `CHANNELS.CHANGED`. +- `c` clears finished processes immediately; the list updates via `CHANNELS.CHANGED`. +- `q` or `esc` closes the panel and restores the editor. +- Selection survives a `CHANNELS.CHANGED` refresh (start/stop/clear) without resetting to the top. +- With no processes, the panel shows a centered empty state. +- Without a UI, `/ps` prints a plain tab-separated process list to the console. -- 2.51.2