From 9b2aa4a683d0f6eee5cc8c6f29b3c15e94682567 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Fri, 2 Jan 2026 02:05:37 +0530 Subject: [PATCH] Hooks can render custom status (#385) * Add ctx.ui.setStatus(key, text) API for hooks to display status in footer - Add setStatus to HookUIContext interface - Implement in interactive mode (FooterComponent) - Implement in RPC mode (fire-and-forget) - Add no-op implementations for headless contexts - Multiple statuses displayed on single line, sorted by key - Supports ANSI styling (hooks handle their own colors) * Remove setStatus from changelog for now * Fix hook status API to follow TUI rules - Sanitize status text: replace newlines, tabs, carriage returns with spaces - Truncate combined status line to terminal width using truncateToWidth - Update JSDoc to document sanitization and truncation behavior - Remove unused createHookUIContext method - Add missing setStatus to test mock * Add setStatus to changelog * Use dim ellipsis for hook status truncation for consistency with footer style --------- Co-authored-by: Mario Zechner --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/hooks.md | 12 ++++- .../src/core/custom-tools/loader.ts | 1 + .../coding-agent/src/core/hooks/runner.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 11 +++++ .../modes/interactive/components/footer.ts | 44 ++++++++++++++++++- .../src/modes/interactive/interactive-mode.ts | 9 ++++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 11 +++++ .../coding-agent/src/modes/rpc/rpc-types.ts | 1 + .../test/compaction-hooks.test.ts | 1 + 10 files changed, 90 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a6c296d7..0969d284 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -35,6 +35,7 @@ The hooks API has been restructured with more granular events and better session - New `pi.registerCommand(name, options)` for custom slash commands - New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering - New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus +- New `ctx.ui.setStatus(key, text)` for persistent status text in footer (multiple hooks can set their own) - `ctx.exec()` moved to `pi.exec()` - `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()` - New `ctx.modelRegistry` and `ctx.model` for API key resolution @@ -189,6 +190,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Added +- `ctx.ui.setStatus(key, text)` for hooks to display persistent status text in the footer ([#385](https://github.com/badlogic/pi-mono/pull/385) by [@prateekmedia](https://github.com/prateekmedia)) - `/share` command to upload session as a secret GitHub gist and get a shareable URL via shittycodingagent.ai ([#380](https://github.com/badlogic/pi-mono/issues/380)) - HTML export now includes a tree visualization sidebar for navigating session branches ([#375](https://github.com/badlogic/pi-mono/issues/375)) - HTML export supports keyboard shortcuts: Ctrl+T to toggle thinking blocks, Ctrl+O to toggle tool outputs diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 76531459..1a9a0570 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -423,6 +423,10 @@ const name = await ctx.ui.input("Name:", "placeholder"); // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" +// Set status text in footer (persistent until cleared) +ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status +ctx.ui.setStatus("my-hook", undefined); // Clear status + // Set the core input editor text (pre-fill prompts, generated content) ctx.ui.setEditorText("Generated prompt text here..."); @@ -430,6 +434,12 @@ ctx.ui.setEditorText("Generated prompt text here..."); const currentText = ctx.ui.getEditorText(); ``` +**Status text notes:** +- Multiple hooks can set their own status using unique keys +- Statuses are displayed on a single line in the footer, sorted alphabetically by key +- Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width +- ANSI escape codes for styling are preserved + **Custom components:** Show a custom TUI component with keyboard focus: @@ -731,7 +741,7 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example | RPC | JSON protocol | Host handles UI | | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | -In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()` is a no-op. Design hooks to handle this by checking `ctx.hasUI`. +In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`. ## Error Handling diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index b7c38472..dd223e18 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext { confirm: async () => false, input: async () => undefined, notify: () => {}, + setStatus: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 6be8b759..7a0782f4 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -39,6 +39,7 @@ const noOpUIContext: HookUIContext = { confirm: async () => false, input: async () => undefined, notify: () => {}, + setStatus: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 65ab9c0d..693ceff0 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -57,6 +57,17 @@ export interface HookUIContext { */ notify(message: string, type?: "info" | "warning" | "error"): void; + /** + * Set status text in the footer/status bar. + * Pass undefined as text to clear the status for this key. + * Text can include ANSI escape codes for styling. + * Note: Newlines, tabs, and carriage returns are replaced with spaces. + * The combined status line is truncated to terminal width. + * @param key - Unique key to identify this status (e.g., hook name) + * @param text - Status text to display, or undefined to clear + */ + setStatus(key: string, text: string | undefined): void; + /** * Show a custom component with keyboard focus. * The factory receives TUI, theme, and a done() callback to close the component. diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 347404dc..78db717c 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,10 +1,22 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { type Component, visibleWidth } from "@mariozechner/pi-tui"; +import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; import { dirname, join } from "path"; import type { AgentSession } from "../../../core/agent-session.js"; import { theme } from "../theme/theme.js"; +/** + * Sanitize text for display in a single-line status. + * Removes newlines, tabs, carriage returns, and other control characters. + */ +function sanitizeStatusText(text: string): string { + // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces + return text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim(); +} + /** * Find the git root directory by walking up from cwd. * Returns the path to .git/HEAD if found, null otherwise. @@ -34,6 +46,7 @@ export class FooterComponent implements Component { private gitWatcher: FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; + private hookStatuses: Map = new Map(); constructor(session: AgentSession) { this.session = session; @@ -43,6 +56,21 @@ export class FooterComponent implements Component { this.autoCompactEnabled = enabled; } + /** + * Set hook status text to display in the footer. + * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width. + * ANSI escape codes for styling are preserved. + * @param key - Unique key to identify this status + * @param text - Status text, or undefined to clear + */ + setHookStatus(key: string, text: string | undefined): void { + if (text === undefined) { + this.hookStatuses.delete(key); + } else { + this.hookStatuses.set(key, text); + } + } + /** * Set up a file watcher on .git/HEAD to detect branch changes. * Call the provided callback when branch changes. @@ -279,6 +307,18 @@ export class FooterComponent implements Component { const remainder = statsLine.slice(statsLeft.length); // padding + rightSide const dimRemainder = theme.fg("dim", remainder); - return [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; + const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; + + // Add hook statuses on a single line, sorted by key alphabetically + if (this.hookStatuses.size > 0) { + const sortedStatuses = Array.from(this.hookStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => sanitizeStatusText(text)); + const statusLine = sortedStatuses.join(" "); + // Truncate to terminal width with dim ellipsis for consistency with footer style + lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + } + + return lines; } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9b9e6f7d..00b03a00 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -371,6 +371,7 @@ export class InteractiveMode { confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), + setStatus: (key, text) => this.setHookStatus(key, text), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), @@ -459,6 +460,14 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Set hook status text in the footer. + */ + private setHookStatus(key: string, text: string | undefined): void { + this.footer.setHookStatus(key, text); + this.ui.requestRender(); + } + /** * Show a selector for hooks. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 75f33db6..ba274421 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -119,6 +119,17 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, + setStatus(key: string, text: string | undefined): void { + // Fire and forget - no response needed + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "setStatus", + statusKey: key, + statusText: text, + } as RpcHookUIRequest); + }, + async custom() { // Custom UI not supported in RPC mode return undefined as never; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 8ab21247..e75a41dc 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -182,6 +182,7 @@ export type RpcHookUIRequest = message: string; notifyType?: "info" | "warning" | "error"; } + | { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index a4fd1eea..09d47a90 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -108,6 +108,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { confirm: async () => false, input: async () => undefined, notify: () => {}, + setStatus: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "",