diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8b2ec738..6115762d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -40,15 +40,17 @@ - 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.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 - New example hook: `plan-mode.ts` - Claude Code-style read-only exploration mode: - Toggle via `/plan` command, `Shift+P` shortcut, or `--plan` CLI flag - 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.) - 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 - - 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) ### Changed diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index ffd5bf5f..c062af0e 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -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", 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) 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 - 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:** Use `ctx.ui.theme` to apply consistent colors that respect the user's theme: diff --git a/packages/coding-agent/examples/hooks/plan-mode.ts b/packages/coding-agent/examples/hooks/plan-mode.ts index e1b40aac..1e9e92c3 100644 --- a/packages/coding-agent/examples/hooks/plan-mode.ts +++ b/packages/coding-agent/examples/hooks/plan-mode.ts @@ -229,18 +229,34 @@ export default function planModeHook(pi: HookAPI) { default: false, }); - // Helper to update footer status + // Helper to update status displays function updateStatus(ctx: HookContext) { + // Update footer status if (executionMode && todoItems.length > 0) { const completed = todoItems.filter((t) => t.completed).length; - const total = todoItems.length; - const progress = `${completed}/${total}`; - ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${progress}`)); + ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`)); } else if (planModeEnabled) { ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan")); } else { 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 diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index be936c03..1f72c41e 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -92,6 +92,7 @@ function createNoOpUIContext(): HookUIContext { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index b6e54843..a957115c 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -49,6 +49,7 @@ const noOpUIContext: HookUIContext = { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 592e7ea8..4f336aee 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -74,6 +74,28 @@ export interface HookUIContext { */ 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. * The factory receives TUI, theme, and a done() callback to close the component. diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 639f355d..3e907571 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -141,6 +141,10 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; + // Hook widgets (multi-line status displays) + private hookWidgets = new Map(); + private widgetContainer!: Container; + // Custom tools for custom rendering private customTools: Map; @@ -171,6 +175,7 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); + this.widgetContainer = new Container(); this.keybindings = KeybindingsManager.create(); this.editor = new CustomEditor(getEditorTheme(), this.keybindings); this.editorContainer = new Container(); @@ -326,6 +331,7 @@ export class InteractiveMode { this.ui.addChild(this.chatContainer); this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.statusContainer); + this.ui.addChild(this.widgetContainer); this.ui.addChild(new Spacer(1)); this.ui.addChild(this.editorContainer); this.ui.addChild(this.footer); @@ -614,6 +620,40 @@ export class InteractiveMode { 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. */ @@ -624,6 +664,7 @@ export class InteractiveMode { input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), + setWidget: (key, lines) => this.setHookWidget(key, lines), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 5b5155e6..69b3db7a 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -376,6 +376,10 @@ export class Theme { return chalk.inverse(text); } + strikethrough(text: string): string { + return chalk.strikethrough(text); + } + getFgAnsi(color: ThemeColor): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e4d57d9e..10a31d0d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -131,6 +131,17 @@ export async function runRpcMode(session: AgentSession): Promise { } 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() { // Custom UI not supported in RPC mode return undefined as never; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 69e88236..172b745e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -189,6 +189,13 @@ export type RpcHookUIRequest = notifyType?: "info" | "warning" | "error"; } | { 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 }; // ============================================================================ diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index bf601371..62bd7315 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -118,6 +118,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { input: async () => undefined, notify: () => {}, setStatus: () => {}, + setWidget: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "",