mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
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:
parent
121823c74d
commit
f9064c2f69
8 changed files with 488 additions and 48 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue