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 resize handling", () => { it("triggers full re-render when terminal height changes", 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"]; tui.start(); await terminal.flush(); const initialRedraws = tui.fullRedraws; // Resize height terminal.resize(40, 15); await terminal.flush(); // Should have triggered a full redraw assert.ok( tui.fullRedraws > initialRedraws, "Height change should trigger full redraw", ); const viewport = terminal.getViewport(); assert.ok( viewport[0]?.includes("Line 0"), "Content preserved after height change", ); tui.stop(); }); it("triggers full re-render when terminal width changes", 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"]; tui.start(); await terminal.flush(); const initialRedraws = tui.fullRedraws; // Resize width terminal.resize(60, 10); await terminal.flush(); // Should have triggered a full redraw assert.ok( tui.fullRedraws > initialRedraws, "Width change should trigger full redraw", ); tui.stop(); }); }); describe("TUI content shrinkage", () => { it("clears empty rows when content shrinks significantly", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); // Start with many lines component.lines = [ "Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5", ]; tui.start(); await terminal.flush(); const initialRedraws = tui.fullRedraws; // Shrink to fewer lines component.lines = ["Line 0", "Line 1"]; tui.requestRender(); await terminal.flush(); // Should have triggered a full redraw to clear empty rows assert.ok( tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw", ); const viewport = terminal.getViewport(); assert.ok(viewport[0]?.includes("Line 0"), "First line preserved"); assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved"); // Lines below should be empty (cleared) assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared"); assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared"); tui.stop(); }); it("handles shrink to single line", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"]; tui.start(); await terminal.flush(); // Shrink to single line component.lines = ["Only line"]; tui.requestRender(); await terminal.flush(); const viewport = terminal.getViewport(); assert.ok(viewport[0]?.includes("Only line"), "Single line rendered"); assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared"); tui.stop(); }); it("handles shrink to empty", async () => { const terminal = new VirtualTerminal(40, 10); const tui = new TUI(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); component.lines = ["Line 0", "Line 1", "Line 2"]; tui.start(); await terminal.flush(); // Shrink to empty component.lines = []; tui.requestRender(); await terminal.flush(); const viewport = terminal.getViewport(); // All lines should be empty assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared"); assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared"); tui.stop(); }); }); 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(); }); });