mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
Adds positioning/sizing options for overlays and fixes crash when compositing lines with complex ANSI sequences exceed terminal width.
538 lines
18 KiB
TypeScript
538 lines
18 KiB
TypeScript
import assert from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
import type { Component } from "../src/tui.js";
|
|
import { TUI } from "../src/tui.js";
|
|
import { VirtualTerminal } from "./virtual-terminal.js";
|
|
|
|
class StaticOverlay implements Component {
|
|
constructor(
|
|
private lines: string[],
|
|
public requestedWidth?: number,
|
|
) {}
|
|
|
|
render(width: number): string[] {
|
|
// Store the width we were asked to render at for verification
|
|
this.requestedWidth = width;
|
|
return this.lines;
|
|
}
|
|
|
|
invalidate(): void {}
|
|
}
|
|
|
|
class EmptyContent implements Component {
|
|
render(): string[] {
|
|
return [];
|
|
}
|
|
invalidate(): void {}
|
|
}
|
|
|
|
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
|
|
tui.requestRender(true);
|
|
await new Promise<void>((resolve) => process.nextTick(resolve));
|
|
await terminal.flush();
|
|
}
|
|
|
|
describe("TUI overlay options", () => {
|
|
describe("width overflow protection", () => {
|
|
it("should truncate overlay lines that exceed declared width", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
// Overlay declares width 20 but renders lines much wider
|
|
const overlay = new StaticOverlay(["X".repeat(100)]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { width: 20 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash, and no line should exceed terminal width
|
|
const viewport = terminal.getViewport();
|
|
for (const line of viewport) {
|
|
// visibleWidth not available here, but line length is a rough check
|
|
// The important thing is it didn't crash
|
|
assert.ok(line !== undefined);
|
|
}
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle overlay with complex ANSI sequences without crashing", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
// Simulate complex ANSI content like the crash log showed
|
|
const complexLine =
|
|
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
|
|
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
|
|
" more content ".repeat(10);
|
|
const overlay = new StaticOverlay([complexLine, complexLine, complexLine]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { width: 60 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport.length > 0);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle overlay composited on styled base content", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
|
|
// Base content with styling
|
|
class StyledContent implements Component {
|
|
render(width: number): string[] {
|
|
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
|
|
return [styledLine, styledLine, styledLine];
|
|
}
|
|
invalidate(): void {}
|
|
}
|
|
|
|
const overlay = new StaticOverlay(["OVERLAY"]);
|
|
|
|
tui.addChild(new StyledContent());
|
|
tui.showOverlay(overlay, { width: 20, anchor: "center" });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash and overlay should be visible
|
|
const viewport = terminal.getViewport();
|
|
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
|
|
assert.ok(hasOverlay, "Overlay should be visible");
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle wide characters at overlay boundary", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
// Wide chars (each takes 2 columns) at the edge of declared width
|
|
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
|
|
const overlay = new StaticOverlay([wideCharLine]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport.length > 0);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle overlay positioned at terminal edge", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
// Overlay positioned at right edge with content that exceeds declared width
|
|
const overlay = new StaticOverlay(["X".repeat(50)]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
// Position at col 60 with width 20 - should fit exactly at right edge
|
|
tui.showOverlay(overlay, { col: 60, width: 20 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport.length > 0);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle overlay on base content with OSC sequences", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
|
|
// Base content with OSC 8 hyperlinks (like file paths in agent output)
|
|
class HyperlinkContent implements Component {
|
|
render(width: number): string[] {
|
|
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
|
|
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
|
|
return [line, line, line];
|
|
}
|
|
invalidate(): void {}
|
|
}
|
|
|
|
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
|
|
|
|
tui.addChild(new HyperlinkContent());
|
|
tui.showOverlay(overlay, { anchor: "center", width: 20 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Should not crash - this was the original bug scenario
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport.length > 0);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("widthPercent", () => {
|
|
it("should render overlay at percentage of terminal width", async () => {
|
|
const terminal = new VirtualTerminal(100, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["test"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { widthPercent: 50 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
assert.strictEqual(overlay.requestedWidth, 50);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should respect minWidth when widthPercent results in smaller width", async () => {
|
|
const terminal = new VirtualTerminal(100, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["test"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { widthPercent: 10, minWidth: 30 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
assert.strictEqual(overlay.requestedWidth, 30);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("anchor positioning", () => {
|
|
it("should position overlay at top-left", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["TOP-LEFT"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.startsWith("TOP-LEFT"), `Expected TOP-LEFT at start, got: ${viewport[0]}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should position overlay at bottom-right", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["BTM-RIGHT"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Should be on last row, ending at last column
|
|
const lastRow = viewport[23];
|
|
assert.ok(lastRow?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on last row, got: ${lastRow}`);
|
|
assert.ok(lastRow?.trimEnd().endsWith("BTM-RIGHT"), `Expected BTM-RIGHT at end, got: ${lastRow}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should position overlay at top-center", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["CENTERED"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Should be on first row, centered horizontally
|
|
const firstRow = viewport[0];
|
|
assert.ok(firstRow?.includes("CENTERED"), `Expected CENTERED on first row, got: ${firstRow}`);
|
|
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
|
|
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
|
|
assert.ok(colIndex >= 30 && colIndex <= 40, `Expected centered, got col ${colIndex}`);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("margin", () => {
|
|
it("should clamp negative margins to zero", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["NEG-MARGIN"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
// Negative margins should be treated as 0
|
|
tui.showOverlay(overlay, {
|
|
anchor: "top-left",
|
|
width: 12,
|
|
margin: { top: -5, left: -10, right: 0, bottom: 0 },
|
|
});
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Should be at row 0, col 0 (negative margins clamped to 0)
|
|
assert.ok(viewport[0]?.startsWith("NEG-MARGIN"), `Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should respect margin as number", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["MARGIN"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Should be on row 5 (not 0) due to margin
|
|
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
|
|
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
|
|
assert.ok(viewport[5]?.includes("MARGIN"), `Expected MARGIN on row 5, got: ${viewport[5]}`);
|
|
// Should start at col 5 (not 0)
|
|
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
|
|
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should respect margin object", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["MARGIN"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, {
|
|
anchor: "top-left",
|
|
width: 10,
|
|
margin: { top: 2, left: 3, right: 0, bottom: 0 },
|
|
});
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[2]?.includes("MARGIN"), `Expected MARGIN on row 2, got: ${viewport[2]}`);
|
|
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
|
|
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("offset", () => {
|
|
it("should apply offsetX and offsetY from anchor position", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["OFFSET"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { anchor: "top-left", width: 10, offsetX: 10, offsetY: 5 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[5]?.includes("OFFSET"), `Expected OFFSET on row 5, got: ${viewport[5]}`);
|
|
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
|
|
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("percentage positioning", () => {
|
|
it("should position with rowPercent and colPercent", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["PCT"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
// 50% should center both ways
|
|
tui.showOverlay(overlay, { width: 10, rowPercent: 50, colPercent: 50 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Find the row with PCT
|
|
let foundRow = -1;
|
|
for (let i = 0; i < viewport.length; i++) {
|
|
if (viewport[i]?.includes("PCT")) {
|
|
foundRow = i;
|
|
break;
|
|
}
|
|
}
|
|
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
|
|
assert.ok(foundRow >= 10 && foundRow <= 13, `Expected centered row, got ${foundRow}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("rowPercent 0 should position at top", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["TOP"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { width: 10, rowPercent: 0 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("TOP"), `Expected TOP on row 0, got: ${viewport[0]}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("rowPercent 100 should position at bottom", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["BOTTOM"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { width: 10, rowPercent: 100 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[23]?.includes("BOTTOM"), `Expected BOTTOM on last row, got: ${viewport[23]}`);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("maxHeight", () => {
|
|
it("should truncate overlay to maxHeight", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { maxHeight: 3 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
const content = viewport.join("\n");
|
|
assert.ok(content.includes("Line 1"), "Should include Line 1");
|
|
assert.ok(content.includes("Line 2"), "Should include Line 2");
|
|
assert.ok(content.includes("Line 3"), "Should include Line 3");
|
|
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
|
|
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
|
|
tui.stop();
|
|
});
|
|
|
|
it("should truncate overlay to maxHeightPercent", async () => {
|
|
const terminal = new VirtualTerminal(80, 10);
|
|
const tui = new TUI(terminal);
|
|
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
|
|
const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
tui.showOverlay(overlay, { maxHeightPercent: 50 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
const content = viewport.join("\n");
|
|
assert.ok(content.includes("L1"), "Should include L1");
|
|
assert.ok(content.includes("L5"), "Should include L5");
|
|
assert.ok(!content.includes("L6"), "Should NOT include L6");
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("absolute positioning", () => {
|
|
it("row and col should override anchor", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
const overlay = new StaticOverlay(["ABSOLUTE"]);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
// Even with bottom-right anchor, row/col should win
|
|
tui.showOverlay(overlay, { anchor: "bottom-right", row: 3, col: 5, width: 10 });
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[3]?.includes("ABSOLUTE"), `Expected ABSOLUTE on row 3, got: ${viewport[3]}`);
|
|
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
|
|
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
|
tui.stop();
|
|
});
|
|
});
|
|
|
|
describe("stacked overlays", () => {
|
|
it("should render multiple overlays with later ones on top", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
|
|
// First overlay at top-left
|
|
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
|
|
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
|
|
|
|
// Second overlay at top-left (should cover part of first)
|
|
const overlay2 = new StaticOverlay(["SECOND"]);
|
|
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
|
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Second overlay should be visible (on top)
|
|
assert.ok(viewport[0]?.includes("SECOND"), `Expected SECOND on row 0, got: ${viewport[0]}`);
|
|
// Part of first overlay might still be visible after SECOND
|
|
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
|
|
tui.stop();
|
|
});
|
|
|
|
it("should handle overlays at different positions without interference", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
|
|
// Overlay at top-left
|
|
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
|
|
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
|
|
|
|
// Overlay at bottom-right
|
|
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
|
|
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
|
|
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Both should be visible
|
|
assert.ok(viewport[0]?.includes("TOP-LEFT"), `Expected TOP-LEFT on row 0, got: ${viewport[0]}`);
|
|
assert.ok(viewport[23]?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on row 23, got: ${viewport[23]}`);
|
|
tui.stop();
|
|
});
|
|
|
|
it("should properly hide overlays in stack order", async () => {
|
|
const terminal = new VirtualTerminal(80, 24);
|
|
const tui = new TUI(terminal);
|
|
|
|
tui.addChild(new EmptyContent());
|
|
|
|
// Show two overlays
|
|
const overlay1 = new StaticOverlay(["FIRST"]);
|
|
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
|
|
|
|
const overlay2 = new StaticOverlay(["SECOND"]);
|
|
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
|
|
|
tui.start();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// Second should be visible
|
|
let viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("SECOND"), "SECOND should be visible initially");
|
|
|
|
// Hide top overlay
|
|
tui.hideOverlay();
|
|
await renderAndFlush(tui, terminal);
|
|
|
|
// First should now be visible
|
|
viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("FIRST"), "FIRST should be visible after hiding SECOND");
|
|
|
|
tui.stop();
|
|
});
|
|
});
|
|
});
|