mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
tui-double-buffer: Implement smart differential rendering with terminal abstraction
- Create Terminal interface abstracting stdin/stdout operations for dependency injection - Implement ProcessTerminal for production use with process.stdin/stdout - Implement VirtualTerminal using @xterm/headless for accurate terminal emulation in tests - Fix TypeScript imports for @xterm/headless module - Move all component files to src/components/ directory for better organization - Add comprehensive test suite with async/await patterns for proper render timing - Fix critical TUI differential rendering bug when components grow in height - Issue: Old content wasn't properly cleared when component line count increased - Solution: Clear each old line individually before redrawing, ensure cursor at line start - Add test verifying terminal content preservation and text editor growth behavior - Update tsconfig.json to include test files in type checking - Add benchmark test comparing single vs double buffer performance The implementation successfully reduces flicker by only updating changed lines rather than clearing entire sections. Both TUI implementations maintain the same interface for backward compatibility.
This commit is contained in:
parent
923a9e58ab
commit
afa807b200
19 changed files with 1591 additions and 344 deletions
115
packages/tui/test/bench.ts
Normal file
115
packages/tui/test/bench.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env npx tsx
|
||||
import {
|
||||
Container,
|
||||
LoadingAnimation,
|
||||
TextComponent,
|
||||
TextEditor,
|
||||
TUI,
|
||||
WhitespaceComponent,
|
||||
} from "../src/index.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Test the new smart double-buffered TUI implementation
|
||||
*/
|
||||
async function main() {
|
||||
const ui = new TUI();
|
||||
|
||||
// Track render timings
|
||||
let renderCount = 0;
|
||||
let totalRenderTime = 0n;
|
||||
const renderTimings: bigint[] = [];
|
||||
|
||||
// Monkey-patch requestRender to measure performance
|
||||
const originalRequestRender = ui.requestRender.bind(ui);
|
||||
ui.requestRender = function() {
|
||||
const startTime = process.hrtime.bigint();
|
||||
originalRequestRender();
|
||||
process.nextTick(() => {
|
||||
const endTime = process.hrtime.bigint();
|
||||
const duration = endTime - startTime;
|
||||
renderTimings.push(duration);
|
||||
totalRenderTime += duration;
|
||||
renderCount++;
|
||||
});
|
||||
};
|
||||
|
||||
// Add header
|
||||
const header = new TextComponent(
|
||||
chalk.bold.green("Smart Double Buffer TUI Test") + "\n" +
|
||||
chalk.dim("Testing new implementation with component-level caching and smart diffing") + "\n" +
|
||||
chalk.dim("Press CTRL+C to exit"),
|
||||
{ bottom: 1 }
|
||||
);
|
||||
ui.addChild(header);
|
||||
|
||||
// Add container for animation and editor
|
||||
const container = new Container();
|
||||
|
||||
// Add loading animation (should NOT cause flicker with smart diffing)
|
||||
const animation = new LoadingAnimation(ui);
|
||||
container.addChild(animation);
|
||||
|
||||
// Add some spacing
|
||||
container.addChild(new WhitespaceComponent(1));
|
||||
|
||||
// Add text editor
|
||||
const editor = new TextEditor();
|
||||
editor.setText("Type here to test the text editor.\n\nWith smart diffing, only changed lines are redrawn!\n\nThe animation above updates every 80ms but the editor stays perfectly still.");
|
||||
container.addChild(editor);
|
||||
|
||||
// Add the container to UI
|
||||
ui.addChild(container);
|
||||
|
||||
// Add performance stats display
|
||||
const statsComponent = new TextComponent("", { top: 1 });
|
||||
ui.addChild(statsComponent);
|
||||
|
||||
// Update stats every second
|
||||
const statsInterval = setInterval(() => {
|
||||
if (renderCount > 0) {
|
||||
const avgRenderTime = Number(totalRenderTime / BigInt(renderCount)) / 1_000_000; // Convert to ms
|
||||
const lastRenderTime = renderTimings.length > 0
|
||||
? Number(renderTimings[renderTimings.length - 1]) / 1_000_000
|
||||
: 0;
|
||||
const avgLinesRedrawn = ui.getAverageLinesRedrawn();
|
||||
|
||||
statsComponent.setText(
|
||||
chalk.yellow(`Performance Stats:`) + "\n" +
|
||||
chalk.dim(`Renders: ${renderCount} | Avg Time: ${avgRenderTime.toFixed(2)}ms | Last: ${lastRenderTime.toFixed(2)}ms`) + "\n" +
|
||||
chalk.dim(`Lines Redrawn: ${ui.getLinesRedrawn()} total | Avg per render: ${avgLinesRedrawn.toFixed(1)}`)
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Set focus to the editor
|
||||
ui.setFocus(editor);
|
||||
|
||||
// Handle global keypresses
|
||||
ui.onGlobalKeyPress = (data: string) => {
|
||||
// CTRL+C to exit
|
||||
if (data === "\x03") {
|
||||
animation.stop();
|
||||
clearInterval(statsInterval);
|
||||
ui.stop();
|
||||
console.log("\n" + chalk.green("Exited double-buffer test"));
|
||||
console.log(chalk.dim(`Total renders: ${renderCount}`));
|
||||
console.log(chalk.dim(`Average render time: ${renderCount > 0 ? (Number(totalRenderTime / BigInt(renderCount)) / 1_000_000).toFixed(2) : 0}ms`));
|
||||
console.log(chalk.dim(`Total lines redrawn: ${ui.getLinesRedrawn()}`));
|
||||
console.log(chalk.dim(`Average lines redrawn per render: ${ui.getAverageLinesRedrawn().toFixed(1)}`));
|
||||
process.exit(0);
|
||||
}
|
||||
return true; // Forward other keys to focused component
|
||||
};
|
||||
|
||||
// Start the UI
|
||||
ui.start();
|
||||
}
|
||||
|
||||
// Run the test
|
||||
main().catch((error) => {
|
||||
console.error("Error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
418
packages/tui/test/tui-rendering.test.ts
Normal file
418
packages/tui/test/tui-rendering.test.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
import {
|
||||
TUI,
|
||||
Container,
|
||||
TextComponent,
|
||||
TextEditor,
|
||||
WhitespaceComponent,
|
||||
MarkdownComponent,
|
||||
SelectList,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("TUI Rendering", () => {
|
||||
|
||||
test("renders single text component", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const text = new TextComponent("Hello, World!");
|
||||
ui.addChild(text);
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Wait for writes to complete and get the rendered output
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
// Expected: text on first line
|
||||
assert.strictEqual(output[0], "Hello, World!");
|
||||
|
||||
// Check cursor position
|
||||
const cursor = terminal.getCursorPosition();
|
||||
assert.strictEqual(cursor.y, 1);
|
||||
assert.strictEqual(cursor.x, 0);
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("renders multiple text components", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
ui.addChild(new TextComponent("Line 1"));
|
||||
ui.addChild(new TextComponent("Line 2"));
|
||||
ui.addChild(new TextComponent("Line 3"));
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Line 1");
|
||||
assert.strictEqual(output[1], "Line 2");
|
||||
assert.strictEqual(output[2], "Line 3");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("renders text component with padding", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
ui.addChild(new TextComponent("Top text"));
|
||||
ui.addChild(new TextComponent("Padded text", { top: 2, bottom: 2 }));
|
||||
ui.addChild(new TextComponent("Bottom text"));
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Top text");
|
||||
assert.strictEqual(output[1], ""); // top padding
|
||||
assert.strictEqual(output[2], ""); // top padding
|
||||
assert.strictEqual(output[3], "Padded text");
|
||||
assert.strictEqual(output[4], ""); // bottom padding
|
||||
assert.strictEqual(output[5], ""); // bottom padding
|
||||
assert.strictEqual(output[6], "Bottom text");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("renders container with children", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new TextComponent("Child 1"));
|
||||
container.addChild(new TextComponent("Child 2"));
|
||||
|
||||
ui.addChild(new TextComponent("Before container"));
|
||||
ui.addChild(container);
|
||||
ui.addChild(new TextComponent("After container"));
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Before container");
|
||||
assert.strictEqual(output[1], "Child 1");
|
||||
assert.strictEqual(output[2], "Child 2");
|
||||
assert.strictEqual(output[3], "After container");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("handles text editor rendering", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const editor = new TextEditor();
|
||||
ui.addChild(editor);
|
||||
ui.setFocus(editor);
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Initial state - empty editor with cursor
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
// Check that we have the border characters
|
||||
assert.ok(output[0].includes("╭"));
|
||||
assert.ok(output[0].includes("╮"));
|
||||
assert.ok(output[1].includes("│"));
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("differential rendering only updates changed lines", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const staticText = new TextComponent("Static text");
|
||||
const dynamicText = new TextComponent("Initial");
|
||||
|
||||
ui.addChild(staticText);
|
||||
ui.addChild(dynamicText);
|
||||
|
||||
// Wait for initial render
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
|
||||
// Save initial state
|
||||
const initialViewport = [...terminal.getViewport()];
|
||||
|
||||
// Change only the dynamic text
|
||||
dynamicText.setText("Changed");
|
||||
ui.requestRender();
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
// Flush terminal buffer
|
||||
await terminal.flush();
|
||||
|
||||
// Check the viewport now shows the change
|
||||
const newViewport = terminal.getViewport();
|
||||
assert.strictEqual(newViewport[0], "Static text"); // Unchanged
|
||||
assert.strictEqual(newViewport[1], "Changed"); // Changed
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("handles component removal", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const text1 = new TextComponent("Line 1");
|
||||
const text2 = new TextComponent("Line 2");
|
||||
const text3 = new TextComponent("Line 3");
|
||||
|
||||
ui.addChild(text1);
|
||||
ui.addChild(text2);
|
||||
ui.addChild(text3);
|
||||
|
||||
// Wait for initial render
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
let output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Line 1");
|
||||
assert.strictEqual(output[1], "Line 2");
|
||||
assert.strictEqual(output[2], "Line 3");
|
||||
|
||||
// Remove middle component
|
||||
ui.removeChild(text2);
|
||||
ui.requestRender();
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Line 1");
|
||||
assert.strictEqual(output[1], "Line 3");
|
||||
assert.strictEqual(output[2].trim(), ""); // Should be cleared
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("handles viewport overflow", async () => {
|
||||
const terminal = new VirtualTerminal(80, 10); // Small viewport
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
// Add more lines than viewport can hold
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
ui.addChild(new TextComponent(`Line ${i}`));
|
||||
}
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
// Should only render what fits in viewport (9 lines + 1 for cursor)
|
||||
// When content exceeds viewport, we show the last N lines
|
||||
assert.strictEqual(output[0], "Line 7");
|
||||
assert.strictEqual(output[1], "Line 8");
|
||||
assert.strictEqual(output[2], "Line 9");
|
||||
assert.strictEqual(output[3], "Line 10");
|
||||
assert.strictEqual(output[4], "Line 11");
|
||||
assert.strictEqual(output[5], "Line 12");
|
||||
assert.strictEqual(output[6], "Line 13");
|
||||
assert.strictEqual(output[7], "Line 14");
|
||||
assert.strictEqual(output[8], "Line 15");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("handles whitespace component", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
ui.addChild(new TextComponent("Before"));
|
||||
ui.addChild(new WhitespaceComponent(3));
|
||||
ui.addChild(new TextComponent("After"));
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Before");
|
||||
assert.strictEqual(output[1], "");
|
||||
assert.strictEqual(output[2], "");
|
||||
assert.strictEqual(output[3], "");
|
||||
assert.strictEqual(output[4], "After");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("markdown component renders correctly", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const markdown = new MarkdownComponent("# Hello\n\nThis is **bold** text.");
|
||||
ui.addChild(markdown);
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
// Should have formatted markdown
|
||||
assert.ok(output[0].includes("Hello")); // Header
|
||||
assert.ok(output[2].includes("This is")); // Paragraph after blank line
|
||||
assert.ok(output[2].includes("bold")); // Bold text
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("select list renders and handles selection", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const items = [
|
||||
{ label: "Option 1", value: "1" },
|
||||
{ label: "Option 2", value: "2" },
|
||||
{ label: "Option 3", value: "3" },
|
||||
];
|
||||
|
||||
const selectList = new SelectList(items);
|
||||
ui.addChild(selectList);
|
||||
ui.setFocus(selectList);
|
||||
|
||||
// Wait for next tick for render to complete
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
// First option should be selected (has → indicator)
|
||||
assert.ok(output[0].startsWith("→"), `Expected first line to start with →, got: "${output[0]}"`);
|
||||
assert.ok(output[0].includes("Option 1"));
|
||||
assert.ok(output[1].startsWith(" "), `Expected second line to start with space, got: "${output[1]}"`);
|
||||
assert.ok(output[1].includes("Option 2"));
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
|
||||
test("preserves existing terminal content when rendering", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
// Write some content to the terminal before starting TUI
|
||||
// This simulates having existing content in the scrollback buffer
|
||||
terminal.write("Previous command output line 1\r\n");
|
||||
terminal.write("Previous command output line 2\r\n");
|
||||
terminal.write("Some important information\r\n");
|
||||
terminal.write("Last line before TUI starts\r\n");
|
||||
|
||||
// Flush to ensure writes are complete
|
||||
await terminal.flush();
|
||||
|
||||
// Get the initial state with existing content
|
||||
const initialOutput = [...terminal.getViewport()];
|
||||
assert.strictEqual(initialOutput[0], "Previous command output line 1");
|
||||
assert.strictEqual(initialOutput[1], "Previous command output line 2");
|
||||
assert.strictEqual(initialOutput[2], "Some important information");
|
||||
assert.strictEqual(initialOutput[3], "Last line before TUI starts");
|
||||
|
||||
// Now start the TUI with a text editor
|
||||
const ui = new TUI(terminal);
|
||||
ui.start();
|
||||
|
||||
const editor = new TextEditor();
|
||||
let submittedText = "";
|
||||
editor.onSubmit = (text) => {
|
||||
submittedText = text;
|
||||
};
|
||||
ui.addChild(editor);
|
||||
ui.setFocus(editor);
|
||||
|
||||
// Wait for initial render
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
|
||||
// Check that the editor is rendered after the existing content
|
||||
const afterTuiStart = terminal.getViewport();
|
||||
|
||||
// The existing content should still be visible above the editor
|
||||
assert.strictEqual(afterTuiStart[0], "Previous command output line 1");
|
||||
assert.strictEqual(afterTuiStart[1], "Previous command output line 2");
|
||||
assert.strictEqual(afterTuiStart[2], "Some important information");
|
||||
assert.strictEqual(afterTuiStart[3], "Last line before TUI starts");
|
||||
|
||||
// The editor should appear after the existing content
|
||||
// The editor is 3 lines tall (top border, content line, bottom border)
|
||||
// Top border with box drawing characters filling the width (80 chars)
|
||||
assert.strictEqual(afterTuiStart[4][0], "╭");
|
||||
assert.strictEqual(afterTuiStart[4][78], "╮");
|
||||
|
||||
// Content line should have the prompt
|
||||
assert.strictEqual(afterTuiStart[5].substring(0, 4), "│ > ");
|
||||
// And should end with vertical bar
|
||||
assert.strictEqual(afterTuiStart[5][78], "│");
|
||||
|
||||
// Bottom border
|
||||
assert.strictEqual(afterTuiStart[6][0], "╰");
|
||||
assert.strictEqual(afterTuiStart[6][78], "╯");
|
||||
|
||||
// Type some text into the editor
|
||||
terminal.sendInput("Hello World");
|
||||
|
||||
// Wait for the input to be processed
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
|
||||
// Check that text appears in the editor
|
||||
const afterTyping = terminal.getViewport();
|
||||
assert.strictEqual(afterTyping[0], "Previous command output line 1");
|
||||
assert.strictEqual(afterTyping[1], "Previous command output line 2");
|
||||
assert.strictEqual(afterTyping[2], "Some important information");
|
||||
assert.strictEqual(afterTyping[3], "Last line before TUI starts");
|
||||
|
||||
// The editor content should show the typed text with the prompt ">"
|
||||
assert.strictEqual(afterTyping[5].substring(0, 15), "│ > Hello World");
|
||||
|
||||
// Send SHIFT+ENTER to the editor (adds a new line)
|
||||
// According to text-editor.ts line 251, SHIFT+ENTER is detected as "\n" which calls addNewLine()
|
||||
terminal.sendInput("\n");
|
||||
|
||||
// Wait for the input to be processed
|
||||
await new Promise(resolve => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
|
||||
// Check that existing content is still preserved after adding new line
|
||||
const afterNewLine = terminal.getViewport();
|
||||
assert.strictEqual(afterNewLine[0], "Previous command output line 1");
|
||||
assert.strictEqual(afterNewLine[1], "Previous command output line 2");
|
||||
assert.strictEqual(afterNewLine[2], "Some important information");
|
||||
assert.strictEqual(afterNewLine[3], "Last line before TUI starts");
|
||||
|
||||
// Editor should now be 4 lines tall (top border, first line, second line, bottom border)
|
||||
// Top border at line 4
|
||||
assert.strictEqual(afterNewLine[4][0], "╭");
|
||||
assert.strictEqual(afterNewLine[4][78], "╮");
|
||||
|
||||
// First line with text at line 5
|
||||
assert.strictEqual(afterNewLine[5].substring(0, 15), "│ > Hello World");
|
||||
assert.strictEqual(afterNewLine[5][78], "│");
|
||||
|
||||
// Second line (empty, with continuation prompt " ") at line 6
|
||||
assert.strictEqual(afterNewLine[6].substring(0, 4), "│ ");
|
||||
assert.strictEqual(afterNewLine[6][78], "│");
|
||||
|
||||
// Bottom border at line 7
|
||||
assert.strictEqual(afterNewLine[7][0], "╰");
|
||||
assert.strictEqual(afterNewLine[7][78], "╯");
|
||||
|
||||
// Verify that onSubmit was NOT called (since we pressed SHIFT+ENTER, not plain ENTER)
|
||||
assert.strictEqual(submittedText, "");
|
||||
|
||||
ui.stop();
|
||||
});
|
||||
});
|
||||
158
packages/tui/test/virtual-terminal.test.ts
Normal file
158
packages/tui/test/virtual-terminal.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
|
||||
describe("VirtualTerminal", () => {
|
||||
test("writes and reads simple text", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
terminal.write("Hello, World!");
|
||||
|
||||
// Wait for write to process
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
assert.strictEqual(output[0], "Hello, World!");
|
||||
assert.strictEqual(output[1], "");
|
||||
});
|
||||
|
||||
test("handles newlines correctly", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
terminal.write("Line 1\r\nLine 2\r\nLine 3");
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
assert.strictEqual(output[0], "Line 1");
|
||||
assert.strictEqual(output[1], "Line 2");
|
||||
assert.strictEqual(output[2], "Line 3");
|
||||
});
|
||||
|
||||
test("handles ANSI cursor movement", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
// Write text with proper newlines, move cursor up, overwrite
|
||||
terminal.write("First line\r\nSecond line");
|
||||
terminal.write("\x1b[1A"); // Move up 1 line
|
||||
terminal.write("\rOverwritten");
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
assert.strictEqual(output[0], "Overwritten");
|
||||
assert.strictEqual(output[1], "Second line");
|
||||
});
|
||||
|
||||
test("handles clear line escape sequence", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
terminal.write("This will be cleared");
|
||||
terminal.write("\r\x1b[2K"); // Clear line
|
||||
terminal.write("New text");
|
||||
|
||||
const output = await terminal.flushAndGetViewport();
|
||||
|
||||
assert.strictEqual(output[0], "New text");
|
||||
});
|
||||
|
||||
test("tracks cursor position", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
terminal.write("Hello");
|
||||
await terminal.flush();
|
||||
|
||||
const cursor = terminal.getCursorPosition();
|
||||
assert.strictEqual(cursor.x, 5); // After "Hello"
|
||||
assert.strictEqual(cursor.y, 0); // First line
|
||||
|
||||
terminal.write("\r\nWorld"); // Use CR+LF for proper newline
|
||||
await terminal.flush();
|
||||
|
||||
const cursor2 = terminal.getCursorPosition();
|
||||
assert.strictEqual(cursor2.x, 5); // After "World"
|
||||
assert.strictEqual(cursor2.y, 1); // Second line
|
||||
});
|
||||
|
||||
test("handles viewport overflow with scrolling", async () => {
|
||||
const terminal = new VirtualTerminal(80, 10); // Small viewport
|
||||
|
||||
// Write more lines than viewport can hold
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
terminal.write(`Line ${i}\r\n`);
|
||||
}
|
||||
|
||||
const viewport = await terminal.flushAndGetViewport();
|
||||
const scrollBuffer = terminal.getScrollBuffer();
|
||||
|
||||
// Viewport should show lines 7-15 plus empty line (because viewport starts after scrolling)
|
||||
assert.strictEqual(viewport.length, 10);
|
||||
assert.strictEqual(viewport[0], "Line 7");
|
||||
assert.strictEqual(viewport[8], "Line 15");
|
||||
assert.strictEqual(viewport[9], ""); // Last line is empty after the final \r\n
|
||||
|
||||
// Scroll buffer should have all lines
|
||||
assert.ok(scrollBuffer.length >= 15);
|
||||
// Check specific lines exist in the buffer
|
||||
const hasLine1 = scrollBuffer.some(line => line === "Line 1");
|
||||
const hasLine15 = scrollBuffer.some(line => line === "Line 15");
|
||||
assert.ok(hasLine1, "Buffer should contain 'Line 1'");
|
||||
assert.ok(hasLine15, "Buffer should contain 'Line 15'");
|
||||
});
|
||||
|
||||
test("resize updates dimensions", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
assert.strictEqual(terminal.columns, 80);
|
||||
assert.strictEqual(terminal.rows, 24);
|
||||
|
||||
terminal.resize(100, 30);
|
||||
|
||||
assert.strictEqual(terminal.columns, 100);
|
||||
assert.strictEqual(terminal.rows, 30);
|
||||
});
|
||||
|
||||
test("reset clears terminal completely", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
terminal.write("Some text\r\nMore text");
|
||||
|
||||
let output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "Some text");
|
||||
assert.strictEqual(output[1], "More text");
|
||||
|
||||
terminal.reset();
|
||||
|
||||
output = await terminal.flushAndGetViewport();
|
||||
assert.strictEqual(output[0], "");
|
||||
assert.strictEqual(output[1], "");
|
||||
});
|
||||
|
||||
test("sendInput triggers handler", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
let received = "";
|
||||
terminal.start((data) => {
|
||||
received = data;
|
||||
}, () => {});
|
||||
|
||||
terminal.sendInput("a");
|
||||
assert.strictEqual(received, "a");
|
||||
|
||||
terminal.sendInput("\x1b[A"); // Up arrow
|
||||
assert.strictEqual(received, "\x1b[A");
|
||||
|
||||
terminal.stop();
|
||||
});
|
||||
|
||||
test("resize triggers handler", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
|
||||
let resized = false;
|
||||
terminal.start(() => {}, () => {
|
||||
resized = true;
|
||||
});
|
||||
|
||||
terminal.resize(100, 30);
|
||||
assert.strictEqual(resized, true);
|
||||
|
||||
terminal.stop();
|
||||
});
|
||||
});
|
||||
161
packages/tui/test/virtual-terminal.ts
Normal file
161
packages/tui/test/virtual-terminal.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import xterm from '@xterm/headless';
|
||||
import type { Terminal as XtermTerminalType } from '@xterm/headless';
|
||||
import { Terminal } from '../src/terminal.js';
|
||||
|
||||
// Extract Terminal class from the module
|
||||
const XtermTerminal = xterm.Terminal;
|
||||
|
||||
/**
|
||||
* Virtual terminal for testing using xterm.js for accurate terminal emulation
|
||||
*/
|
||||
export class VirtualTerminal implements Terminal {
|
||||
private xterm: XtermTerminalType;
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private _columns: number;
|
||||
private _rows: number;
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
|
||||
// Create xterm instance with specified dimensions
|
||||
this.xterm = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows: rows,
|
||||
// Disable all interactive features for testing
|
||||
disableStdin: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
// No need for raw mode in virtual terminal
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.inputHandler = undefined;
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.xterm.write(data);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this._columns;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
// Test-specific methods not in Terminal interface
|
||||
|
||||
/**
|
||||
* Simulate keyboard input
|
||||
*/
|
||||
sendInput(data: string): void {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the terminal
|
||||
*/
|
||||
resize(columns: number, rows: number): void {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
this.xterm.resize(columns, rows);
|
||||
if (this.resizeHandler) {
|
||||
this.resizeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
// Write an empty string to ensure all previous writes are flushed
|
||||
return new Promise<void>((resolve) => {
|
||||
this.xterm.write('', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and get viewport - convenience method for tests
|
||||
*/
|
||||
async flushAndGetViewport(): Promise<string[]> {
|
||||
await this.flush();
|
||||
return this.getViewport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible viewport (what's currently on screen)
|
||||
* Note: You should use getViewportAfterWrite() for testing after writing data
|
||||
*/
|
||||
getViewport(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get only the visible lines (viewport)
|
||||
for (let i = 0; i < this.xterm.rows; i++) {
|
||||
const line = buffer.getLine(buffer.viewportY + i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entire scroll buffer
|
||||
*/
|
||||
getScrollBuffer(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get all lines in the buffer (including scrollback)
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal viewport
|
||||
*/
|
||||
clear(): void {
|
||||
this.xterm.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the terminal completely
|
||||
*/
|
||||
reset(): void {
|
||||
this.xterm.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position
|
||||
*/
|
||||
getCursorPosition(): { x: number; y: number } {
|
||||
const buffer = this.xterm.buffer.active;
|
||||
return {
|
||||
x: buffer.cursorX,
|
||||
y: buffer.cursorY
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue