feat(hooks): add setWidgetComponent for custom TUI components

- New ctx.ui.setWidgetComponent(key, factory) method
- Allows custom Component to render as widget without taking focus
- Unlike custom(), widget components render inline above editor
- Components are disposed when cleared or replaced
- Falls back to no-op in RPC/print modes
This commit is contained in:
Helmut Januschka 2026-01-03 21:47:54 +01:00 committed by Mario Zechner
parent 9b53b89bd5
commit ce88ebcd68
8 changed files with 94 additions and 6 deletions

View file

@ -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:

View file

@ -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:**

View file

@ -93,6 +93,7 @@ function createNoOpUIContext(): HookUIContext {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",

View file

@ -50,6 +50,7 @@ const noOpUIContext: HookUIContext = {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",

View file

@ -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.

View file

@ -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<string, string[]>();
private hookWidgetComponents = new Map<string, Component & { dispose?(): void }>();
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(),

View file

@ -142,6 +142,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} 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;

View file

@ -121,6 +121,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
notify: () => {},
setStatus: () => {},
setWidget: () => {},
setWidgetComponent: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",