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

@ -8,7 +8,7 @@ import * as path from "node:path";
import { isKeyRelease, matchesKey } from "./keys.js";
import type { Terminal } from "./terminal.js";
import { getCapabilities, setCellDimensions } from "./terminal-image.js";
import { visibleWidth } from "./utils.js";
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
/**
* Component interface - all components must implement this
@ -93,6 +93,13 @@ export class TUI extends Container {
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
// Overlay stack for modal components rendered on top of base content
private overlayStack: {
component: Component;
options?: { row?: number; col?: number; width?: number };
preFocus: Component | null;
}[] = [];
constructor(terminal: Terminal) {
super();
this.terminal = terminal;
@ -102,6 +109,32 @@ export class TUI extends Container {
this.focusedComponent = component;
}
/** Show an overlay component centered (or at specified position). */
showOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {
this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
this.setFocus(component);
this.terminal.hideCursor();
this.requestRender();
}
/** Hide the topmost overlay and restore previous focus. */
hideOverlay(): void {
const overlay = this.overlayStack.pop();
if (!overlay) return;
this.setFocus(overlay.preFocus);
if (this.overlayStack.length === 0) this.terminal.hideCursor();
this.requestRender();
}
hasOverlay(): boolean {
return this.overlayStack.length > 0;
}
override invalidate(): void {
super.invalidate();
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
}
start(): void {
this.terminal.start(
(data) => this.handleInput(data),
@ -215,12 +248,88 @@ export class TUI extends Container {
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
}
/** Composite all overlays into content lines (in stack order, later = on top). */
private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
if (this.overlayStack.length === 0) return lines;
const result = [...lines];
const viewportStart = Math.max(0, result.length - termHeight);
for (const { component, options } of this.overlayStack) {
const w =
options?.width !== undefined
? Math.max(1, Math.min(options.width, termWidth - 4))
: Math.max(1, Math.min(80, termWidth - 4));
const overlayLines = component.render(w);
const h = overlayLines.length;
const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));
const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));
for (let i = 0; i < h; i++) {
const idx = viewportStart + row + i;
if (idx >= 0 && idx < result.length) {
result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);
}
}
}
return result;
}
private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
private compositeLineAt(
baseLine: string,
overlayLine: string,
startCol: number,
overlayWidth: number,
totalWidth: number,
): string {
if (this.containsImage(baseLine)) return baseLine;
// Single pass through baseLine extracts both before and after segments
const afterStart = startCol + overlayWidth;
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
// Extract overlay with width tracking
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth);
// Pad segments to target widths
const beforePad = Math.max(0, startCol - base.beforeWidth);
const overlayPad = Math.max(0, overlayWidth - overlay.width);
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
const afterPad = Math.max(0, afterTarget - base.afterWidth);
// Compose result - widths are tracked so no final visibleWidth check needed
const r = TUI.SEGMENT_RESET;
const result =
base.before +
" ".repeat(beforePad) +
r +
overlay.text +
" ".repeat(overlayPad) +
r +
base.after +
" ".repeat(afterPad);
// Only truncate if wide char at after boundary caused overflow (rare)
const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);
return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
}
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
// Render all components to get new lines
const newLines = this.render(width);
let newLines = this.render(width);
// Composite overlays into the rendered lines (before differential compare)
if (this.overlayStack.length > 0) {
newLines = this.compositeOverlays(newLines, width, height);
}
// Width changed - need full re-render
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;