feat(tui): add overlay compositing for ctx.ui.custom() (#558)

Adds overlay rendering capability to the TUI, enabling floating modal
components that render on top of existing content without clearing the screen.

- Add showOverlay(), hideOverlay(), hasOverlay() methods to TUI
- Implement ANSI-aware line compositing via extractSegments()
- Support overlay stack (multiple overlays, later on top)
- Add { overlay: true } option to ctx.ui.custom()
- Add overlay-test.ts example extension

Also fixes pre-existing bug where bash tool output cached visual lines
at fixed terminal width, causing crashes on terminal resize.

Co-authored-by: Nico Bailon <nico.bailon@gmail.com>
This commit is contained in:
Mario Zechner 2026-01-08 22:40:42 +01:00
parent 121823c74d
commit f9064c2f69
8 changed files with 488 additions and 48 deletions

View file

@ -99,6 +99,7 @@ export interface ExtensionUIContext {
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T>;
/** Set the text in the core input editor. */

View file

@ -355,24 +355,29 @@ export class ToolExecutionComponent extends Container {
// Show all lines when expanded
this.contentBox.addChild(new Text(`\n${styledOutput}`, 0, 0));
} else {
// Use visual line truncation when collapsed
// Box has paddingX=1, so content width = terminal.columns - 2
const { visualLines, skippedCount } = truncateToVisualLines(
`\n${styledOutput}`,
BASH_PREVIEW_LINES,
this.ui.terminal.columns - 2,
);
// Use visual line truncation when collapsed with width-aware caching
const textContent = `\n${styledOutput}`;
let cachedWidth: number | undefined;
let cachedLines: string[] | undefined;
let cachedSkipped: number | undefined;
if (skippedCount > 0) {
this.contentBox.addChild(
new Text(theme.fg("toolOutput", `\n... (${skippedCount} earlier lines)`), 0, 0),
);
}
// Add pre-rendered visual lines as a raw component
this.contentBox.addChild({
render: () => visualLines,
invalidate: () => {},
render: (width: number) => {
if (cachedLines === undefined || cachedWidth !== width) {
const result = truncateToVisualLines(textContent, BASH_PREVIEW_LINES, width);
cachedLines = result.visualLines;
cachedSkipped = result.skippedCount;
cachedWidth = width;
}
return cachedSkipped && cachedSkipped > 0
? ["", theme.fg("toolOutput", `... (${cachedSkipped} earlier lines)`), ...cachedLines]
: cachedLines;
},
invalidate: () => {
cachedWidth = undefined;
cachedLines = undefined;
cachedSkipped = undefined;
},
});
}
}

View file

@ -932,7 +932,7 @@ export class InteractiveMode {
setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory),
setTitle: (title) => this.ui.terminal.setTitle(title),
custom: (factory) => this.showExtensionCustom(factory),
custom: (factory, options) => this.showExtensionCustom(factory, options),
setEditorText: (text) => this.editor.setText(text),
getEditorText: () => this.editor.getText(),
editor: (title, prefill) => this.showExtensionEditor(title, prefill),
@ -1188,9 +1188,7 @@ export class InteractiveMode {
}
}
/**
* Show a custom component with keyboard focus.
*/
/** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
private async showExtensionCustom<T>(
factory: (
tui: TUI,
@ -1198,29 +1196,56 @@ export class InteractiveMode {
keybindings: KeybindingsManager,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T> {
const savedText = this.editor.getText();
const isOverlay = options?.overlay ?? false;
return new Promise((resolve) => {
const restoreEditor = () => {
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
this.ui.requestRender();
};
return new Promise((resolve, reject) => {
let component: Component & { dispose?(): void };
let closed = false;
const close = (result: T) => {
component.dispose?.();
this.editorContainer.clear();
this.editorContainer.addChild(this.editor);
this.editor.setText(savedText);
this.ui.setFocus(this.editor);
this.ui.requestRender();
if (closed) return;
closed = true;
if (isOverlay) this.ui.hideOverlay();
else restoreEditor();
// Note: both branches above already call requestRender
resolve(result);
try {
component?.dispose?.();
} catch {
/* ignore dispose errors */
}
};
Promise.resolve(factory(this.ui, theme, this.keybindings, close)).then((c) => {
component = c;
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
});
Promise.resolve(factory(this.ui, theme, this.keybindings, close))
.then((c) => {
if (closed) return;
component = c;
if (isOverlay) {
const w = (component as { width?: number }).width;
this.ui.showOverlay(component, w ? { width: w } : undefined);
} else {
this.editorContainer.clear();
this.editorContainer.addChild(component);
this.ui.setFocus(component);
this.ui.requestRender();
}
})
.catch((err) => {
if (closed) return;
if (!isOverlay) restoreEditor();
reject(err);
});
});
}