mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 15:02:32 +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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -135,21 +135,29 @@ export function visibleWidth(str: string): number {
|
|||
/**
|
||||
* Extract ANSI escape sequences from a string at the given position.
|
||||
*/
|
||||
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") {
|
||||
export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b") return null;
|
||||
|
||||
const next = str[pos + 1];
|
||||
|
||||
// CSI sequence: ESC [ ... m/G/K/H/J
|
||||
if (next === "[") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
|
||||
if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
return null;
|
||||
}
|
||||
|
||||
let j = pos + 2;
|
||||
while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) {
|
||||
j++;
|
||||
}
|
||||
|
||||
if (j < str.length) {
|
||||
return {
|
||||
code: str.substring(pos, j + 1),
|
||||
length: j + 1 - pos,
|
||||
};
|
||||
// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
|
||||
// Used for hyperlinks (OSC 8), window titles, etc.
|
||||
if (next === "]") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length) {
|
||||
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -308,6 +316,11 @@ class AnsiCodeTracker {
|
|||
this.bgColor = null;
|
||||
}
|
||||
|
||||
/** Clear all state for reuse. */
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getActiveCodes(): string {
|
||||
const codes: string[] = [];
|
||||
if (this.bold) codes.push("1");
|
||||
|
|
@ -711,3 +724,140 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
|
|||
// Add reset code before ellipsis to prevent styling leaking into it
|
||||
return `${result}\x1b[0m${ellipsis}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
||||
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
||||
*/
|
||||
export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
|
||||
return sliceWithWidth(line, startCol, length, strict).text;
|
||||
}
|
||||
|
||||
/** Like sliceByColumn but also returns the actual visible width of the result. */
|
||||
export function sliceWithWidth(
|
||||
line: string,
|
||||
startCol: number,
|
||||
length: number,
|
||||
strict = false,
|
||||
): { text: string; width: number } {
|
||||
if (length <= 0) return { text: "", width: 0 };
|
||||
const endCol = startCol + length;
|
||||
let result = "",
|
||||
resultWidth = 0,
|
||||
currentCol = 0,
|
||||
i = 0,
|
||||
pendingAnsi = "";
|
||||
|
||||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
|
||||
else if (currentCol < startCol) pendingAnsi += ansi.code;
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let textEnd = i;
|
||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||
|
||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
||||
const w = graphemeWidth(segment);
|
||||
const inRange = currentCol >= startCol && currentCol < endCol;
|
||||
const fits = !strict || currentCol + w <= endCol;
|
||||
if (inRange && fits) {
|
||||
if (pendingAnsi) {
|
||||
result += pendingAnsi;
|
||||
pendingAnsi = "";
|
||||
}
|
||||
result += segment;
|
||||
resultWidth += w;
|
||||
}
|
||||
currentCol += w;
|
||||
if (currentCol >= endCol) break;
|
||||
}
|
||||
i = textEnd;
|
||||
if (currentCol >= endCol) break;
|
||||
}
|
||||
return { text: result, width: resultWidth };
|
||||
}
|
||||
|
||||
// Pooled tracker instance for extractSegments (avoids allocation per call)
|
||||
const pooledStyleTracker = new AnsiCodeTracker();
|
||||
|
||||
/**
|
||||
* Extract "before" and "after" segments from a line in a single pass.
|
||||
* Used for overlay compositing where we need content before and after the overlay region.
|
||||
* Preserves styling from before the overlay that should affect content after it.
|
||||
*/
|
||||
export function extractSegments(
|
||||
line: string,
|
||||
beforeEnd: number,
|
||||
afterStart: number,
|
||||
afterLen: number,
|
||||
strictAfter = false,
|
||||
): { before: string; beforeWidth: number; after: string; afterWidth: number } {
|
||||
let before = "",
|
||||
beforeWidth = 0,
|
||||
after = "",
|
||||
afterWidth = 0;
|
||||
let currentCol = 0,
|
||||
i = 0;
|
||||
let pendingAnsiBefore = "";
|
||||
let afterStarted = false;
|
||||
const afterEnd = afterStart + afterLen;
|
||||
|
||||
// Track styling state so "after" inherits styling from before the overlay
|
||||
pooledStyleTracker.clear();
|
||||
|
||||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
// Track all SGR codes to know styling state at afterStart
|
||||
pooledStyleTracker.process(ansi.code);
|
||||
// Include ANSI codes in their respective segments
|
||||
if (currentCol < beforeEnd) {
|
||||
pendingAnsiBefore += ansi.code;
|
||||
} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
|
||||
// Only include after we've started "after" (styling already prepended)
|
||||
after += ansi.code;
|
||||
}
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let textEnd = i;
|
||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||
|
||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
||||
const w = graphemeWidth(segment);
|
||||
|
||||
if (currentCol < beforeEnd) {
|
||||
if (pendingAnsiBefore) {
|
||||
before += pendingAnsiBefore;
|
||||
pendingAnsiBefore = "";
|
||||
}
|
||||
before += segment;
|
||||
beforeWidth += w;
|
||||
} else if (currentCol >= afterStart && currentCol < afterEnd) {
|
||||
const fits = !strictAfter || currentCol + w <= afterEnd;
|
||||
if (fits) {
|
||||
// On first "after" grapheme, prepend inherited styling from before overlay
|
||||
if (!afterStarted) {
|
||||
after += pooledStyleTracker.getActiveCodes();
|
||||
afterStarted = true;
|
||||
}
|
||||
after += segment;
|
||||
afterWidth += w;
|
||||
}
|
||||
}
|
||||
|
||||
currentCol += w;
|
||||
// Early exit: done with "before" only, or done with both segments
|
||||
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
|
||||
}
|
||||
i = textEnd;
|
||||
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
|
||||
}
|
||||
|
||||
return { before, beforeWidth, after, afterWidth };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue