mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 20:03:05 +00:00
Adds positioning/sizing options for overlays and fixes crash when compositing lines with complex ANSI sequences exceed terminal width.
199 lines
6.8 KiB
TypeScript
199 lines
6.8 KiB
TypeScript
import assert from "node:assert";
|
|
import { describe, it } from "node:test";
|
|
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
|
import { type Component, TUI } from "../src/tui.js";
|
|
import { VirtualTerminal } from "./virtual-terminal.js";
|
|
|
|
class TestComponent implements Component {
|
|
lines: string[] = [];
|
|
render(_width: number): string[] {
|
|
return this.lines;
|
|
}
|
|
invalidate(): void {}
|
|
}
|
|
|
|
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
|
|
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
|
const buffer = xterm.buffer.active;
|
|
const line = buffer.getLine(buffer.viewportY + row);
|
|
assert.ok(line, `Missing buffer line at row ${row}`);
|
|
const cell = line.getCell(col);
|
|
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
|
return cell.isItalic();
|
|
}
|
|
|
|
describe("TUI differential rendering", () => {
|
|
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Initial render: 5 identical lines
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
// cursorRow should be 2 (last line of new content)
|
|
// Verify by doing another render with a change on line 1
|
|
component.lines = ["Line 0", "CHANGED", "Line 2"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
// Line 1 should show "CHANGED", proving cursor tracking was correct
|
|
assert.ok(viewport[1]?.includes("CHANGED"), `Expected "CHANGED" on line 1, got: ${viewport[1]}`);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when only a middle line changes (spinner case)", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Initial render
|
|
component.lines = ["Header", "Working...", "Footer"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Simulate spinner animation - only middle line changes
|
|
const spinnerFrames = ["|", "/", "-", "\\"];
|
|
for (const frame of spinnerFrames) {
|
|
component.lines = ["Header", `Working ${frame}`, "Footer"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`);
|
|
assert.ok(viewport[1]?.includes(`Working ${frame}`), `Spinner updated: ${viewport[1]}`);
|
|
assert.ok(viewport[2]?.includes("Footer"), `Footer preserved: ${viewport[2]}`);
|
|
}
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("resets styles after each rendered line", async () => {
|
|
const terminal = new VirtualTerminal(20, 6);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["\x1b[3mItalic", "Plain"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when first line changes but rest stays same", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change only first line
|
|
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`);
|
|
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
|
|
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
|
assert.ok(viewport[3]?.includes("Line 3"), `Line 3 preserved: ${viewport[3]}`);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when last line changes but rest stays same", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change only last line
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
|
|
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
|
|
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
|
assert.ok(viewport[3]?.includes("CHANGED"), `Last line changed: ${viewport[3]}`);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("renders correctly when multiple non-adjacent lines change", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
// Change lines 1 and 3, keep 0, 2, 4 the same
|
|
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
const viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
|
|
assert.ok(viewport[1]?.includes("CHANGED 1"), `Line 1 changed: ${viewport[1]}`);
|
|
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
|
|
assert.ok(viewport[3]?.includes("CHANGED 3"), `Line 3 changed: ${viewport[3]}`);
|
|
assert.ok(viewport[4]?.includes("Line 4"), `Line 4 preserved: ${viewport[4]}`);
|
|
|
|
tui.stop();
|
|
});
|
|
|
|
it("handles transition from content to empty and back to content", async () => {
|
|
const terminal = new VirtualTerminal(40, 10);
|
|
const tui = new TUI(terminal);
|
|
const component = new TestComponent();
|
|
tui.addChild(component);
|
|
|
|
// Start with content
|
|
component.lines = ["Line 0", "Line 1", "Line 2"];
|
|
tui.start();
|
|
await terminal.flush();
|
|
|
|
let viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
|
|
|
|
// Clear to empty
|
|
component.lines = [];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
// Add content back - this should work correctly even after empty state
|
|
component.lines = ["New Line 0", "New Line 1"];
|
|
tui.requestRender();
|
|
await terminal.flush();
|
|
|
|
viewport = terminal.getViewport();
|
|
assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`);
|
|
assert.ok(viewport[1]?.includes("New Line 1"), `New content line 1: ${viewport[1]}`);
|
|
|
|
tui.stop();
|
|
});
|
|
});
|