mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
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:
parent
9b53b89bd5
commit
ce88ebcd68
8 changed files with 94 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setWidgetComponent: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const noOpUIContext: HookUIContext = {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setWidgetComponent: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
notify: () => {},
|
||||
setStatus: () => {},
|
||||
setWidget: () => {},
|
||||
setWidgetComponent: () => {},
|
||||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue