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

@ -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(),