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

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

View file

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

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

View file

@ -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: () => "",