diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8e447272..b410e778 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -42,6 +42,7 @@ - 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`). Conflicts with built-in shortcuts are skipped, conflicts between hooks logged as warnings. - Hook API: `ctx.ui.setWidget(key, lines)` for multi-line status displays above the editor (todo lists, progress tracking) +- Hook API: `ctx.ui.setWidgetComponent(key, factory)` for custom TUI components as widgets (no focus, renders inline) - 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: diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 9343fdeb..ac8d70c1 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -446,9 +446,25 @@ const currentText = ctx.ui.getEditorText(); **Widget notes:** - Widgets are multi-line displays shown above the editor (below "Working..." indicator) - Multiple hooks can set widgets using unique keys (all widgets are displayed, stacked vertically) -- Use for progress lists, todo tracking, or any multi-line status +- Use `setWidget()` for simple styled text, `setWidgetComponent()` for custom components - Supports ANSI styling via `ctx.ui.theme` (including `strikethrough`) -- **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. +- **Caution:** Keep widgets small (a few lines). Large widgets from multiple hooks can cause viewport overflow and TUI flicker. Max 10 lines total across all string widgets. + +**Custom widget components:** + +For more complex widgets, use `setWidgetComponent()` to render a custom TUI component: + +```typescript +ctx.ui.setWidgetComponent("my-widget", (tui, theme) => { + // Return any Component that implements render(width): string[] + return new MyCustomComponent(tui, theme); +}); + +// Clear the widget +ctx.ui.setWidgetComponent("my-widget", undefined); +``` + +Unlike `ctx.ui.custom()`, widget components do NOT take keyboard focus - they render inline above the editor. **Styling with theme colors:** diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 1f72c41e..517f3f42 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -93,6 +93,7 @@ function createNoOpUIContext(): HookUIContext { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, 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 3024b15c..d6155184 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -50,6 +50,7 @@ const noOpUIContext: HookUIContext = { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, 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 3f71d427..8ddec584 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -79,6 +79,8 @@ export interface HookUIContext { * Supports multi-line content. Pass undefined to clear. * Text can include ANSI escape codes for styling. * + * For simple text displays, use this method. For custom components, use setWidgetComponent(). + * * @param key - Unique key to identify this widget (e.g., hook name) * @param lines - Array of lines to display, or undefined to clear * @@ -96,6 +98,31 @@ export interface HookUIContext { */ setWidget(key: string, lines: string[] | undefined): void; + /** + * Set a custom component as a widget (above the editor, below "Working..." indicator). + * Unlike custom(), this does NOT take keyboard focus - the editor remains focused. + * Pass undefined to clear the widget. + * + * The component should implement render(width) and optionally dispose(). + * Components are rendered inline without taking focus - they cannot handle keyboard input. + * + * @param key - Unique key to identify this widget (e.g., hook name) + * @param factory - Function that creates the component, or undefined to clear + * + * @example + * // Show a custom progress component + * ctx.ui.setWidgetComponent("my-progress", (tui, theme) => { + * return new MyProgressComponent(tui, theme); + * }); + * + * // Clear the widget + * ctx.ui.setWidgetComponent("my-progress", undefined); + */ + setWidgetComponent( + key: string, + factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | 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 8c175950..c219b29b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -141,8 +141,9 @@ export class InteractiveMode { private hookInput: HookInputComponent | undefined = undefined; private hookEditor: HookEditorComponent | undefined = undefined; - // Hook widgets (multi-line status displays) + // Hook widgets (multi-line status displays or custom components) private hookWidgets = new Map(); + private hookWidgetComponents = new Map(); private widgetContainer!: Container; // Custom tools for custom rendering @@ -628,11 +629,39 @@ export class InteractiveMode { if (lines === undefined) { this.hookWidgets.delete(key); } else { + // Clear any component widget with same key + const existing = this.hookWidgetComponents.get(key); + if (existing?.dispose) existing.dispose(); + this.hookWidgetComponents.delete(key); + this.hookWidgets.set(key, lines); } this.renderWidgets(); } + /** + * Set a hook widget component (custom component without focus). + */ + private setHookWidgetComponent( + key: string, + factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, + ): void { + // Dispose existing component + const existing = this.hookWidgetComponents.get(key); + if (existing?.dispose) existing.dispose(); + + if (factory === undefined) { + this.hookWidgetComponents.delete(key); + } else { + // Clear any string widget with same key + this.hookWidgets.delete(key); + + const component = factory(this.ui, theme); + this.hookWidgetComponents.set(key, component); + } + this.renderWidgets(); + } + // Maximum total widget lines to prevent viewport overflow private static readonly MAX_WIDGET_LINES = 10; @@ -643,17 +672,19 @@ export class InteractiveMode { if (!this.widgetContainer) return; this.widgetContainer.clear(); - if (this.hookWidgets.size === 0) { + const hasStringWidgets = this.hookWidgets.size > 0; + const hasComponentWidgets = this.hookWidgetComponents.size > 0; + + if (!hasStringWidgets && !hasComponentWidgets) { this.ui.requestRender(); return; } - // Render each widget, respecting max lines to prevent viewport overflow + // Render string widgets first, respecting max lines let totalLines = 0; for (const [_key, lines] of this.hookWidgets) { for (const line of lines) { if (totalLines >= InteractiveMode.MAX_WIDGET_LINES) { - // Add truncation indicator and stop this.widgetContainer.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); this.ui.requestRender(); return; @@ -663,6 +694,11 @@ export class InteractiveMode { } } + // Render component widgets + for (const [_key, component] of this.hookWidgetComponents) { + this.widgetContainer.addChild(component); + } + this.ui.requestRender(); } @@ -677,6 +713,7 @@ export class InteractiveMode { notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), setWidget: (key, lines) => this.setHookWidget(key, lines), + setWidgetComponent: (key, factory) => this.setHookWidgetComponent(key, factory), custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index a0505a62..c35e6222 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -142,6 +142,10 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, + setWidgetComponent(): void { + // Custom components not supported in RPC mode - host would need to implement + }, + async custom() { // Custom UI not supported in RPC mode return undefined as never; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 99f2c1ab..7c2a21e7 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -121,6 +121,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { notify: () => {}, setStatus: () => {}, setWidget: () => {}, + setWidgetComponent: () => {}, custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "",