mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 04:02:35 +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
|
|
@ -16,6 +16,7 @@
|
|||
- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult`
|
||||
- `ssh.ts` example: remote tool execution via `--ssh user@host:/path`
|
||||
- Wayland clipboard support for `/copy` command using wl-copy with xclip/xsel fallback ([#570](https://github.com/badlogic/pi-mono/pull/570) by [@OgulcanCelik](https://github.com/OgulcanCelik))
|
||||
- `ctx.ui.custom()` now accepts `{ overlay: true }` option for floating modal components that composite over existing content without clearing the screen ([#558](https://github.com/badlogic/pi-mono/pull/558) by [@nicobailon](https://github.com/nicobailon))
|
||||
|
||||
### Fixed
|
||||
|
||||
|
|
|
|||
145
packages/coding-agent/examples/extensions/overlay-test.ts
Normal file
145
packages/coding-agent/examples/extensions/overlay-test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Overlay Test - validates overlay compositing with inline text inputs
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/overlay-test.ts
|
||||
*
|
||||
* Run /overlay-test to show a floating overlay with:
|
||||
* - Inline text inputs within menu items
|
||||
* - Edge case tests (wide chars, styled text, emoji)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("overlay-test", {
|
||||
description: "Test overlay rendering with edge cases",
|
||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
||||
const result = await ctx.ui.custom<{ action: string; query?: string } | undefined>(
|
||||
(_tui, theme, _keybindings, done) => new OverlayTestComponent(theme, done),
|
||||
{ overlay: true },
|
||||
);
|
||||
|
||||
if (result) {
|
||||
const msg = result.query ? `${result.action}: "${result.query}"` : result.action;
|
||||
ctx.ui.notify(msg, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class OverlayTestComponent {
|
||||
readonly width = 70;
|
||||
|
||||
private selected = 0;
|
||||
private items = [
|
||||
{ label: "Search", hasInput: true, text: "", cursor: 0 },
|
||||
{ label: "Run", hasInput: true, text: "", cursor: 0 },
|
||||
{ label: "Settings", hasInput: false, text: "", cursor: 0 },
|
||||
{ label: "Cancel", hasInput: false, text: "", cursor: 0 },
|
||||
];
|
||||
|
||||
constructor(
|
||||
private theme: Theme,
|
||||
private done: (result: { action: string; query?: string } | undefined) => void,
|
||||
) {}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, "escape")) {
|
||||
this.done(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.items[this.selected]!;
|
||||
|
||||
if (matchesKey(data, "return")) {
|
||||
this.done({ action: current.label, query: current.hasInput ? current.text : undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, "up")) {
|
||||
this.selected = Math.max(0, this.selected - 1);
|
||||
} else if (matchesKey(data, "down")) {
|
||||
this.selected = Math.min(this.items.length - 1, this.selected + 1);
|
||||
} else if (current.hasInput) {
|
||||
if (matchesKey(data, "backspace")) {
|
||||
if (current.cursor > 0) {
|
||||
current.text = current.text.slice(0, current.cursor - 1) + current.text.slice(current.cursor);
|
||||
current.cursor--;
|
||||
}
|
||||
} else if (matchesKey(data, "left")) {
|
||||
current.cursor = Math.max(0, current.cursor - 1);
|
||||
} else if (matchesKey(data, "right")) {
|
||||
current.cursor = Math.min(current.text.length, current.cursor + 1);
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
current.text = current.text.slice(0, current.cursor) + data + current.text.slice(current.cursor);
|
||||
current.cursor++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(_width: number): string[] {
|
||||
const w = this.width;
|
||||
const th = this.theme;
|
||||
const innerW = w - 2;
|
||||
const lines: string[] = [];
|
||||
|
||||
const pad = (s: string, len: number) => {
|
||||
const vis = visibleWidth(s);
|
||||
return s + " ".repeat(Math.max(0, len - vis));
|
||||
};
|
||||
|
||||
const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
|
||||
|
||||
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
||||
lines.push(row(` ${th.fg("accent", "🧪 Overlay Test")}`));
|
||||
lines.push(row(""));
|
||||
|
||||
// Edge cases - full width lines to test compositing at boundaries
|
||||
lines.push(row(` ${th.fg("dim", "─── Edge Cases (borders should align) ───")}`));
|
||||
lines.push(row(` Wide: ${th.fg("warning", "中文日本語한글テスト漢字繁體简体ひらがなカタカナ가나다라마바")}`));
|
||||
lines.push(
|
||||
row(
|
||||
` Styled: ${th.fg("error", "RED")} ${th.fg("success", "GREEN")} ${th.fg("warning", "YELLOW")} ${th.fg("accent", "ACCENT")} ${th.fg("dim", "DIM")} ${th.fg("error", "more")} ${th.fg("success", "colors")}`,
|
||||
),
|
||||
);
|
||||
lines.push(row(" Emoji: 👨👩👧👦 🇯🇵 🚀 💻 🎉 🔥 😀 🎯 🌟 💡 🎨 🔧 📦 🏆 🌈 🎪 🎭 🎬 🎮 🎲"));
|
||||
lines.push(row(""));
|
||||
|
||||
// Menu with inline inputs
|
||||
lines.push(row(` ${th.fg("dim", "─── Actions ───")}`));
|
||||
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const item = this.items[i]!;
|
||||
const isSelected = i === this.selected;
|
||||
const prefix = isSelected ? " ▶ " : " ";
|
||||
|
||||
let content: string;
|
||||
if (item.hasInput) {
|
||||
const label = isSelected ? th.fg("accent", `${item.label}:`) : th.fg("text", `${item.label}:`);
|
||||
|
||||
let inputDisplay = item.text;
|
||||
if (isSelected) {
|
||||
const before = inputDisplay.slice(0, item.cursor);
|
||||
const cursorChar = item.cursor < inputDisplay.length ? inputDisplay[item.cursor] : " ";
|
||||
const after = inputDisplay.slice(item.cursor + 1);
|
||||
inputDisplay = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
||||
}
|
||||
content = `${prefix + label} ${inputDisplay}`;
|
||||
} else {
|
||||
content = prefix + (isSelected ? th.fg("accent", item.label) : th.fg("text", item.label));
|
||||
}
|
||||
|
||||
lines.push(row(content));
|
||||
}
|
||||
|
||||
lines.push(row(""));
|
||||
lines.push(row(` ${th.fg("dim", "↑↓ navigate • type to input • Enter select • Esc cancel")}`));
|
||||
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
dispose(): void {}
|
||||
}
|
||||
|
|
@ -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