mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
feat(hooks): add setWidget API for multi-line status displays
- ctx.ui.setWidget(key, lines) for multi-line displays above editor - Widgets appear below 'Working...' indicator, above editor - Supports ANSI styling including strikethrough - Added theme.strikethrough() method - Plan-mode hook now shows todo list with checkboxes - Completed items show checked box and strikethrough text
This commit is contained in:
parent
537d672f17
commit
dc44816051
11 changed files with 127 additions and 6 deletions
|
|
@ -40,15 +40,17 @@
|
||||||
- Hook API: `pi.getTools()` and `pi.setTools(toolNames)` for dynamically enabling/disabling tools from hooks
|
- Hook API: `pi.getTools()` and `pi.setTools(toolNames)` for dynamically enabling/disabling tools from hooks
|
||||||
- Hook API: `pi.registerFlag(name, options)` and `pi.getFlag(name)` for hooks to register custom CLI flags (parsed automatically)
|
- Hook API: `pi.registerFlag(name, options)` and `pi.getFlag(name)` for hooks to register custom CLI flags (parsed automatically)
|
||||||
- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts (e.g., `shift+p`, `ctrl+shift+x`)
|
- Hook API: `pi.registerShortcut(shortcut, options)` for hooks to register custom keyboard shortcuts (e.g., `shift+p`, `ctrl+shift+x`)
|
||||||
|
- Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking)
|
||||||
|
- Hook API: `theme.strikethrough(text)` for strikethrough text styling
|
||||||
- `/hotkeys` command now shows hook-registered shortcuts in a separate "Hooks" section
|
- `/hotkeys` command now shows hook-registered shortcuts in a separate "Hooks" section
|
||||||
- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode:
|
- New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode:
|
||||||
- Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag
|
- Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag
|
||||||
- Read-only tools: `read`, `bash`, `grep`, `find`, `ls` (no `edit`/`write`)
|
- Read-only tools: `read`, `bash`, `grep`, `find`, `ls` (no `edit`/`write`)
|
||||||
- Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.)
|
- Bash commands restricted to non-destructive operations (blocks `rm`, `mv`, `git commit`, `npm install`, etc.)
|
||||||
- Interactive prompt after each response: execute plan, stay in plan mode, or refine
|
- Interactive prompt after each response: execute plan, stay in plan mode, or refine
|
||||||
- Todo list extraction from numbered plans with progress tracking (`📋 2/5` in footer)
|
- Todo list widget showing progress with checkboxes and strikethrough for completed items
|
||||||
- `/todos` command to view current plan progress
|
- `/todos` command to view current plan progress
|
||||||
- Shows `⏸ plan` indicator in footer when active
|
- Shows `⏸ plan` indicator in footer when in plan mode, `📋 2/5` when executing
|
||||||
- State persists across sessions (including todo progress)
|
- State persists across sessions (including todo progress)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,15 @@ ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
|
||||||
ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
|
ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
|
||||||
ctx.ui.setStatus("my-hook", undefined); // Clear status
|
ctx.ui.setStatus("my-hook", undefined); // Clear status
|
||||||
|
|
||||||
|
// Set a multi-line widget (displayed above editor, below "Working..." indicator)
|
||||||
|
ctx.ui.setWidget("my-todos", [
|
||||||
|
theme.fg("accent", "Plan Progress:"),
|
||||||
|
theme.fg("success", "☑ ") + theme.fg("muted", theme.strikethrough("Read files")),
|
||||||
|
theme.fg("muted", "☐ ") + "Modify code",
|
||||||
|
theme.fg("muted", "☐ ") + "Run tests",
|
||||||
|
]);
|
||||||
|
ctx.ui.setWidget("my-todos", undefined); // Clear widget
|
||||||
|
|
||||||
// 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...");
|
||||||
|
|
||||||
|
|
@ -434,6 +443,12 @@ const currentText = ctx.ui.getEditorText();
|
||||||
- Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
|
- Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
|
||||||
- Use `ctx.ui.theme` to style status text with theme colors (see below)
|
- Use `ctx.ui.theme` to style status text with theme colors (see below)
|
||||||
|
|
||||||
|
**Widget notes:**
|
||||||
|
- Widgets are multi-line displays shown above the editor (below "Working..." indicator)
|
||||||
|
- Multiple hooks can set widgets using unique keys
|
||||||
|
- Use for progress lists, todo tracking, or any multi-line status
|
||||||
|
- Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`)
|
||||||
|
|
||||||
**Styling with theme colors:**
|
**Styling with theme colors:**
|
||||||
|
|
||||||
Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
|
Use `ctx.ui.theme` to apply consistent colors that respect the user's theme:
|
||||||
|
|
|
||||||
|
|
@ -229,18 +229,34 @@ export default function planModeHook(pi: HookAPI) {
|
||||||
default: false,
|
default: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to update footer status
|
// Helper to update status displays
|
||||||
function updateStatus(ctx: HookContext) {
|
function updateStatus(ctx: HookContext) {
|
||||||
|
// Update footer status
|
||||||
if (executionMode && todoItems.length > 0) {
|
if (executionMode && todoItems.length > 0) {
|
||||||
const completed = todoItems.filter((t) => t.completed).length;
|
const completed = todoItems.filter((t) => t.completed).length;
|
||||||
const total = todoItems.length;
|
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
||||||
const progress = `${completed}/${total}`;
|
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${progress}`));
|
|
||||||
} else if (planModeEnabled) {
|
} else if (planModeEnabled) {
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.setStatus("plan-mode", undefined);
|
ctx.ui.setStatus("plan-mode", undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update widget with todo list
|
||||||
|
if (executionMode && todoItems.length > 0) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const item of todoItems) {
|
||||||
|
if (item.completed) {
|
||||||
|
lines.push(
|
||||||
|
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(ctx.ui.theme.fg("muted", "☐ ") + item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.ui.setWidget("plan-todos", lines);
|
||||||
|
} else {
|
||||||
|
ctx.ui.setWidget("plan-todos", undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to toggle plan mode
|
// Helper to toggle plan mode
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ function createNoOpUIContext(): HookUIContext {
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
setStatus: () => {},
|
setStatus: () => {},
|
||||||
|
setWidget: () => {},
|
||||||
custom: async () => undefined as never,
|
custom: async () => undefined as never,
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const noOpUIContext: HookUIContext = {
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
setStatus: () => {},
|
setStatus: () => {},
|
||||||
|
setWidget: () => {},
|
||||||
custom: async () => undefined as never,
|
custom: async () => undefined as never,
|
||||||
setEditorText: () => {},
|
setEditorText: () => {},
|
||||||
getEditorText: () => "",
|
getEditorText: () => "",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,28 @@ export interface HookUIContext {
|
||||||
*/
|
*/
|
||||||
setStatus(key: string, text: string | undefined): void;
|
setStatus(key: string, text: string | undefined): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a widget to display in the status area (above the editor, below "Working..." indicator).
|
||||||
|
* Supports multi-line content. Pass undefined to clear.
|
||||||
|
* Text can include ANSI escape codes for styling.
|
||||||
|
*
|
||||||
|
* @param key - Unique key to identify this widget (e.g., hook name)
|
||||||
|
* @param lines - Array of lines to display, or undefined to clear
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Show a todo list
|
||||||
|
* ctx.ui.setWidget("plan-todos", [
|
||||||
|
* theme.fg("accent", "Plan Progress:"),
|
||||||
|
* "☑ " + theme.fg("muted", theme.strikethrough("Step 1: Read files")),
|
||||||
|
* "☐ Step 2: Modify code",
|
||||||
|
* "☐ Step 3: Run tests",
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* // Clear the widget
|
||||||
|
* ctx.ui.setWidget("plan-todos", undefined);
|
||||||
|
*/
|
||||||
|
setWidget(key: string, lines: 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.
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,10 @@ export class InteractiveMode {
|
||||||
private hookInput: HookInputComponent | undefined = undefined;
|
private hookInput: HookInputComponent | undefined = undefined;
|
||||||
private hookEditor: HookEditorComponent | undefined = undefined;
|
private hookEditor: HookEditorComponent | undefined = undefined;
|
||||||
|
|
||||||
|
// Hook widgets (multi-line status displays)
|
||||||
|
private hookWidgets = new Map<string, string[]>();
|
||||||
|
private widgetContainer!: Container;
|
||||||
|
|
||||||
// Custom tools for custom rendering
|
// Custom tools for custom rendering
|
||||||
private customTools: Map<string, LoadedCustomTool>;
|
private customTools: Map<string, LoadedCustomTool>;
|
||||||
|
|
||||||
|
|
@ -171,6 +175,7 @@ export class InteractiveMode {
|
||||||
this.chatContainer = new Container();
|
this.chatContainer = new Container();
|
||||||
this.pendingMessagesContainer = new Container();
|
this.pendingMessagesContainer = new Container();
|
||||||
this.statusContainer = new Container();
|
this.statusContainer = new Container();
|
||||||
|
this.widgetContainer = new Container();
|
||||||
this.keybindings = KeybindingsManager.create();
|
this.keybindings = KeybindingsManager.create();
|
||||||
this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
|
this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
|
||||||
this.editorContainer = new Container();
|
this.editorContainer = new Container();
|
||||||
|
|
@ -326,6 +331,7 @@ export class InteractiveMode {
|
||||||
this.ui.addChild(this.chatContainer);
|
this.ui.addChild(this.chatContainer);
|
||||||
this.ui.addChild(this.pendingMessagesContainer);
|
this.ui.addChild(this.pendingMessagesContainer);
|
||||||
this.ui.addChild(this.statusContainer);
|
this.ui.addChild(this.statusContainer);
|
||||||
|
this.ui.addChild(this.widgetContainer);
|
||||||
this.ui.addChild(new Spacer(1));
|
this.ui.addChild(new Spacer(1));
|
||||||
this.ui.addChild(this.editorContainer);
|
this.ui.addChild(this.editorContainer);
|
||||||
this.ui.addChild(this.footer);
|
this.ui.addChild(this.footer);
|
||||||
|
|
@ -614,6 +620,40 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a hook widget (multi-line status display).
|
||||||
|
*/
|
||||||
|
private setHookWidget(key: string, lines: string[] | undefined): void {
|
||||||
|
if (lines === undefined) {
|
||||||
|
this.hookWidgets.delete(key);
|
||||||
|
} else {
|
||||||
|
this.hookWidgets.set(key, lines);
|
||||||
|
}
|
||||||
|
this.renderWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all hook widgets to the widget container.
|
||||||
|
*/
|
||||||
|
private renderWidgets(): void {
|
||||||
|
if (!this.widgetContainer) return;
|
||||||
|
this.widgetContainer.clear();
|
||||||
|
|
||||||
|
if (this.hookWidgets.size === 0) {
|
||||||
|
this.ui.requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render each widget
|
||||||
|
for (const [_key, lines] of this.hookWidgets) {
|
||||||
|
for (const line of lines) {
|
||||||
|
this.widgetContainer.addChild(new Text(line, 1, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the HookUIContext for hooks and tools.
|
* Create the HookUIContext for hooks and tools.
|
||||||
*/
|
*/
|
||||||
|
|
@ -624,6 +664,7 @@ export class InteractiveMode {
|
||||||
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),
|
setStatus: (key, text) => this.setHookStatus(key, text),
|
||||||
|
setWidget: (key, lines) => this.setHookWidget(key, lines),
|
||||||
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(),
|
||||||
|
|
|
||||||
|
|
@ -376,6 +376,10 @@ export class Theme {
|
||||||
return chalk.inverse(text);
|
return chalk.inverse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
strikethrough(text: string): string {
|
||||||
|
return chalk.strikethrough(text);
|
||||||
|
}
|
||||||
|
|
||||||
getFgAnsi(color: ThemeColor): string {
|
getFgAnsi(color: ThemeColor): string {
|
||||||
const ansi = this.fgColors.get(color);
|
const ansi = this.fgColors.get(color);
|
||||||
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
} as RpcHookUIRequest);
|
} as RpcHookUIRequest);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWidget(key: string, lines: string[] | undefined): void {
|
||||||
|
// Fire and forget - host can implement widget display
|
||||||
|
output({
|
||||||
|
type: "hook_ui_request",
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
method: "setWidget",
|
||||||
|
widgetKey: key,
|
||||||
|
widgetLines: lines,
|
||||||
|
} 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;
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,13 @@ export type RpcHookUIRequest =
|
||||||
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: "setStatus"; statusKey: string; statusText: string | undefined }
|
||||||
|
| {
|
||||||
|
type: "hook_ui_request";
|
||||||
|
id: string;
|
||||||
|
method: "setWidget";
|
||||||
|
widgetKey: string;
|
||||||
|
widgetLines: 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 };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
||||||
input: async () => undefined,
|
input: async () => undefined,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
setStatus: () => {},
|
setStatus: () => {},
|
||||||
|
setWidget: () => {},
|
||||||
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