mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +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
|
|
@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const noOpUIContext: HookUIContext = {
|
|||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue