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 <badlogicgames@gmail.com>
This commit is contained in:
Prateek Sunal 2026-01-02 02:05:37 +05:30 committed by GitHub
parent 89db7ed024
commit 9b2aa4a683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 90 additions and 3 deletions

View file

@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",

View file

@ -39,6 +39,7 @@ const noOpUIContext: HookUIContext = {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
setStatus: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",

View file

@ -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.

View file

@ -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<string, string> = 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;
}
}

View file

@ -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.
*/

View file

@ -119,6 +119,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} 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;

View file

@ -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 };
// ============================================================================