mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 16:01:05 +00:00
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:
parent
89db7ed024
commit
9b2aa4a683
10 changed files with 90 additions and 3 deletions
|
|
@ -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.registerCommand(name, options)` for custom slash commands
|
||||||
- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering
|
- 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.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.exec()` moved to `pi.exec()`
|
||||||
- `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()`
|
- `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()`
|
||||||
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
|
- 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
|
### 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))
|
- `/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 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
|
- HTML export supports keyboard shortcuts: Ctrl+T to toggle thinking blocks, Ctrl+O to toggle tool outputs
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,10 @@ const name = await ctx.ui.input("Name:", "placeholder");
|
||||||
// Notification (non-blocking)
|
// Notification (non-blocking)
|
||||||
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
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)
|
// Set the core input editor text (pre-fill prompts, generated content)
|
||||||
ctx.ui.setEditorText("Generated prompt text here...");
|
ctx.ui.setEditorText("Generated prompt text here...");
|
||||||
|
|
||||||
|
|
@ -430,6 +434,12 @@ ctx.ui.setEditorText("Generated prompt text here...");
|
||||||
const currentText = ctx.ui.getEditorText();
|
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:**
|
**Custom components:**
|
||||||
|
|
||||||
Show a custom TUI component with keyboard focus:
|
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 |
|
| RPC | JSON protocol | Host handles UI |
|
||||||
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
|
| 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
|
## Error Handling
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
setStatus: () => {},
|
||||||
custom: async () => undefined as never,
|
custom: async () => undefined as never,
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const noOpUIContext: HookUIContext = {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
setStatus: () => {},
|
||||||
custom: async () => undefined as never,
|
custom: async () => undefined as never,
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,17 @@ export interface HookUIContext {
|
||||||
*/
|
*/
|
||||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
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.
|
* Show a custom component with keyboard focus.
|
||||||
* The factory receives TUI, theme, and a done() callback to close the component.
|
* The factory receives TUI, theme, and a done() callback to close the component.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
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 { existsSync, type FSWatcher, readFileSync, watch } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import type { AgentSession } from "../../../core/agent-session.js";
|
import type { AgentSession } from "../../../core/agent-session.js";
|
||||||
import { theme } from "../theme/theme.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.
|
* Find the git root directory by walking up from cwd.
|
||||||
* Returns the path to .git/HEAD if found, null otherwise.
|
* 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 gitWatcher: FSWatcher | null = null;
|
||||||
private onBranchChange: (() => void) | null = null;
|
private onBranchChange: (() => void) | null = null;
|
||||||
private autoCompactEnabled: boolean = true;
|
private autoCompactEnabled: boolean = true;
|
||||||
|
private hookStatuses: Map<string, string> = new Map();
|
||||||
|
|
||||||
constructor(session: AgentSession) {
|
constructor(session: AgentSession) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
|
@ -43,6 +56,21 @@ export class FooterComponent implements Component {
|
||||||
this.autoCompactEnabled = enabled;
|
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.
|
* Set up a file watcher on .git/HEAD to detect branch changes.
|
||||||
* Call the provided callback when 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 remainder = statsLine.slice(statsLeft.length); // padding + rightSide
|
||||||
const dimRemainder = theme.fg("dim", remainder);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,7 @@ export class InteractiveMode {
|
||||||
confirm: (title, message) => this.showHookConfirm(title, message),
|
confirm: (title, message) => this.showHookConfirm(title, message),
|
||||||
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
||||||
notify: (message, type) => this.showHookNotify(message, type),
|
notify: (message, type) => this.showHookNotify(message, type),
|
||||||
|
setStatus: (key, text) => this.setHookStatus(key, text),
|
||||||
custom: (factory) => this.showHookCustom(factory),
|
custom: (factory) => this.showHookCustom(factory),
|
||||||
setEditorText: (text) => this.editor.setText(text),
|
setEditorText: (text) => this.editor.setText(text),
|
||||||
getEditorText: () => this.editor.getText(),
|
getEditorText: () => this.editor.getText(),
|
||||||
|
|
@ -459,6 +460,14 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
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.
|
* Show a selector for hooks.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
} as RpcHookUIRequest);
|
} 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() {
|
async custom() {
|
||||||
// Custom UI not supported in RPC mode
|
// Custom UI not supported in RPC mode
|
||||||
return undefined as never;
|
return undefined as never;
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,7 @@ export type RpcHookUIRequest =
|
||||||
message: string;
|
message: string;
|
||||||
notifyType?: "info" | "warning" | "error";
|
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 };
|
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
setStatus: () => {},
|
||||||
custom: async () => undefined as never,
|
custom: async () => undefined as never,
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue