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:
Mario Zechner 2025-08-10 22:33:03 +02:00
parent 923a9e58ab
commit afa807b200
19 changed files with 1591 additions and 344 deletions

View file

@ -8,6 +8,7 @@
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json",
"check": "biome check --write .",
"test": "node --test --import tsx test/*.test.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"files": [
@ -33,12 +34,15 @@
"engines": {
"node": ">=20.0.0"
},
"devDependencies": {},
"types": "./dist/index.d.ts",
"dependencies": {
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
},
"devDependencies": {
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0"
}
}

View file

@ -0,0 +1,51 @@
import chalk from "chalk";
import type { TUI } from "../tui.js";
import { TextComponent } from "./text-component.js";
/**
* LoadingAnimation component that updates every 80ms
* Simulates the animation component that causes flicker in single-buffer mode
*/
export class LoadingAnimation extends TextComponent {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private message: string = "Loading...",
) {
super("", { bottom: 1 });
this.ui = ui;
this.start();
}
start() {
this.updateDisplay();
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string) {
this.message = message;
this.updateDisplay();
}
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}

View file

@ -1,8 +1,9 @@
import chalk from "chalk";
import { marked, type Token } from "marked";
import type { Component, ComponentRenderResult } from "./tui.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
export class MarkdownComponent implements Component {
readonly id = getNextComponentId();
private text: string;
private lines: string[] = [];
private previousLines: string[] = [];

View file

@ -1,5 +1,5 @@
import chalk from "chalk";
import type { Component, ComponentRenderResult } from "./tui.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
export interface SelectItem {
value: string;
@ -8,6 +8,7 @@ export interface SelectItem {
}
export class SelectList implements Component {
readonly id = getNextComponentId();
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;

View file

@ -1,6 +1,7 @@
import type { Component, ComponentRenderResult, Padding } from "./tui.js";
import { type Component, type ComponentRenderResult, getNextComponentId, type Padding } from "../tui.js";
export class TextComponent implements Component {
readonly id = getNextComponentId();
private text: string;
private lastRenderedLines: string[] = [];
private padding: Required<Padding>;

View file

@ -1,8 +1,8 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "./autocomplete.js";
import { logger } from "./logger.js";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { logger } from "../logger.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
import { SelectList } from "./select-list.js";
import type { Component, ComponentRenderResult } from "./tui.js";
interface EditorState {
lines: string[];
@ -21,6 +21,7 @@ export interface TextEditorConfig {
}
export class TextEditor implements Component {
readonly id = getNextComponentId();
private state: EditorState = {
lines: [""],
cursorLine: 0,
@ -64,7 +65,7 @@ export class TextEditor implements Component {
const horizontal = chalk.gray("─");
const vertical = chalk.gray("│");
// Calculate box width (leave some margin)
// Calculate box width - leave 1 char margin to avoid edge wrapping
const boxWidth = width - 1;
const contentWidth = boxWidth - 4; // Account for "│ " and " │"

View file

@ -1,9 +1,10 @@
import type { Component, ComponentRenderResult } from "./tui.js";
import { type Component, type ComponentRenderResult, getNextComponentId } from "../tui.js";
/**
* A simple component that renders blank lines for spacing
*/
export class WhitespaceComponent implements Component {
readonly id = getNextComponentId();
private lines: string[] = [];
private lineCount: number;
private firstRender: boolean = true;

View file

@ -7,23 +7,27 @@ export {
CombinedAutocompleteProvider,
type SlashCommand,
} from "./autocomplete.js";
// Loading animation component
export { LoadingAnimation } from "./components/loading-animation.js";
// Markdown component
export { MarkdownComponent } from "./components/markdown-component.js";
// Select list component
export { type SelectItem, SelectList } from "./components/select-list.js";
// Text component
export { TextComponent } from "./components/text-component.js";
// Text editor component
export { TextEditor, type TextEditorConfig } from "./components/text-editor.js";
// Whitespace component
export { WhitespaceComponent } from "./components/whitespace-component.js";
// Logger for debugging
export { type LoggerConfig, logger } from "./logger.js";
// Markdown component
export { MarkdownComponent } from "./markdown-component.js";
// Select list component
export { type SelectItem, SelectList } from "./select-list.js";
// Text component
export { TextComponent } from "./text-component.js";
// Text editor component
export { TextEditor, type TextEditorConfig } from "./text-editor.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
export {
type Component,
type ComponentRenderResult,
Container,
type ContainerRenderResult,
getNextComponentId,
type Padding,
TUI,
} from "./tui.js";
// Whitespace component
export { WhitespaceComponent } from "./whitespace-component.js";

View file

@ -0,0 +1,72 @@
/**
* Minimal terminal interface for TUI
*/
export interface Terminal {
// Start the terminal with input and resize handlers
start(onInput: (data: string) => void, onResize: () => void): void;
// Stop the terminal and restore state
stop(): void;
// Write output to terminal
write(data: string): void;
// Get terminal dimensions
get columns(): number;
get rows(): number;
}
/**
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Save previous state and enable raw mode
this.wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
// Set up event handlers
process.stdin.on("data", this.inputHandler);
process.stdout.on("resize", this.resizeHandler);
}
stop(): void {
// Remove event handlers
if (this.inputHandler) {
process.stdin.removeListener("data", this.inputHandler);
this.inputHandler = undefined;
}
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = undefined;
}
// Restore raw mode state
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
}
write(data: string): void {
process.stdout.write(data);
}
get columns(): number {
return process.stdout.columns || 80;
}
get rows(): number {
return process.stdout.rows || 24;
}
}

View file

@ -1,7 +1,33 @@
import { writeSync } from "fs";
import process from "process";
import { logger } from "./logger.js";
import { ProcessTerminal, type Terminal } from "./terminal.js";
/**
* Result of rendering a component
*/
export interface ComponentRenderResult {
lines: string[];
changed: boolean;
}
/**
* Component interface
*/
export interface Component {
readonly id: number;
render(width: number): ComponentRenderResult;
handleInput?(keyData: string): void;
}
// Global component ID counter
let nextComponentId = 1;
// Helper to get next component ID
export function getNextComponentId(): number {
return nextComponentId++;
}
// Padding type for components
export interface Padding {
top?: number;
bottom?: number;
@ -9,223 +35,134 @@ export interface Padding {
right?: number;
}
export interface ComponentRenderResult {
lines: string[];
changed: boolean;
}
/**
* Container for managing child components
*/
export class Container implements Component {
readonly id: number;
protected children: (Component | Container)[] = [];
private tui?: TUI;
export interface ContainerRenderResult extends ComponentRenderResult {
keepLines: number;
}
export interface Component {
render(width: number): ComponentRenderResult;
handleInput?(keyData: string): void;
}
// Sentinel component used to mark removed components - triggers cascade rendering
class SentinelComponent implements Component {
render(): ComponentRenderResult {
return {
lines: [],
changed: true, // Always trigger cascade
};
}
}
// Base Container class that manages child components
export class Container {
protected children: Element[] = [];
protected lines: string[] = [];
protected parentTui: TUI | undefined; // Reference to parent TUI for triggering re-renders
constructor(parentTui?: TUI | undefined) {
this.parentTui = parentTui;
constructor() {
this.id = getNextComponentId();
}
setParentTui(tui: TUI | undefined): void {
this.parentTui = tui;
setTui(tui: TUI | undefined): void {
this.tui = tui;
for (const child of this.children) {
if (child instanceof Container) {
child.setTui(tui);
}
}
}
addChild(component: Element): void {
addChild(component: Component | Container): void {
this.children.push(component);
// Set parent TUI reference for nested containers
if (component instanceof Container && this.parentTui) {
component.setParentTui(this.parentTui);
}
if (this.parentTui) {
this.parentTui.requestRender();
if (component instanceof Container) {
component.setTui(this.tui);
}
this.tui?.requestRender();
}
removeChild(component: Element): void {
removeChild(component: Component | Container): void {
const index = this.children.indexOf(component);
if (index >= 0) {
// Replace with sentinel instead of splicing to maintain array structure
this.children[index] = new SentinelComponent();
// Keep the childTotalLines entry - sentinel will update it to 0
// Clear parent TUI reference for nested containers
this.children.splice(index, 1);
if (component instanceof Container) {
component.setParentTui(undefined);
}
// Use normal render - sentinel will trigger cascade naturally
if (this.parentTui) {
this.parentTui.requestRender();
}
} else {
for (const child of this.children) {
if (child instanceof Container) {
child.removeChild(component);
}
component.setTui(undefined);
}
this.tui?.requestRender();
}
}
removeChildAt(index: number): void {
if (index >= 0 && index < this.children.length) {
const component = this.children[index];
// Replace with sentinel instead of splicing to maintain array structure
this.children[index] = new SentinelComponent();
// Clear parent TUI reference for nested containers
this.children.splice(index, 1);
if (component instanceof Container) {
component.setParentTui(undefined);
}
// Use normal render - sentinel will trigger cascade naturally
if (this.parentTui) {
this.parentTui.requestRender();
component.setTui(undefined);
}
this.tui?.requestRender();
}
}
render(width: number): ContainerRenderResult {
let keepLines = 0;
let changed = false;
const newLines: string[] = [];
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i];
if (!child) continue;
clear(): void {
for (const child of this.children) {
if (child instanceof Container) {
const result = child.render(width);
newLines.push(...result.lines);
if (!changed && !result.changed) {
keepLines += result.lines.length;
} else {
if (!changed) {
// First change - use the child's keepLines
changed = true;
keepLines += result.keepLines;
}
// After first change, don't add any more keepLines
}
} else {
const result = child.render(width);
newLines.push(...result.lines);
if (!changed && !result.changed) {
keepLines += result.lines.length;
} else {
if (!changed) {
// First change for a non-container component
changed = true;
}
// After first change, don't add any more keepLines
}
child.setTui(undefined);
}
}
this.lines = newLines;
return {
lines: this.lines,
changed,
keepLines,
};
this.children = [];
this.tui?.requestRender();
}
// Get child for external manipulation
// Get child at index
// Note: This may return a SentinelComponent if a child was removed but not yet cleaned up
getChild(index: number): Element | undefined {
getChild(index: number): (Component | Container) | undefined {
return this.children[index];
}
// Get number of children
// Note: This count includes sentinel components until they are cleaned up after the next render pass
getChildCount(): number {
return this.children.length;
}
// Clear all children from the container
clear(): void {
// Clear parent TUI references for nested containers
render(width: number): ComponentRenderResult {
const lines: string[] = [];
let changed = false;
for (const child of this.children) {
if (child instanceof Container) {
child.setParentTui(undefined);
const result = child.render(width);
lines.push(...result.lines);
if (result.changed) {
changed = true;
}
}
// Clear the children array
this.children = [];
// Request render if we have a parent TUI
if (this.parentTui) {
this.parentTui.requestRender();
}
}
// Clean up sentinel components
cleanupSentinels(): void {
const originalCount = this.children.length;
const validChildren: Element[] = [];
let sentinelCount = 0;
for (const child of this.children) {
if (child && !(child instanceof SentinelComponent)) {
validChildren.push(child);
// Recursively clean up nested containers
if (child instanceof Container) {
child.cleanupSentinels();
}
} else if (child instanceof SentinelComponent) {
sentinelCount++;
}
}
this.children = validChildren;
if (sentinelCount > 0) {
logger.debug("Container", "Cleaned up sentinels", {
originalCount,
newCount: this.children.length,
sentinelsRemoved: sentinelCount,
});
}
return { lines, changed };
}
}
type Element = Component | Container;
/**
* Render command for tracking component output
*/
interface RenderCommand {
id: number;
lines: string[];
changed: boolean;
}
/**
* TUI - Smart differential rendering TUI implementation.
*/
export class TUI extends Container {
private focusedComponent: Component | null = null;
private needsRender: boolean = false;
private wasRaw: boolean = false;
private totalLines: number = 0;
private isFirstRender: boolean = true;
private isStarted: boolean = false;
private needsRender = false;
private isFirstRender = true;
private isStarted = false;
public onGlobalKeyPress?: (data: string) => boolean;
private terminal: Terminal;
constructor() {
super(); // No parent TUI for root
// Tracking for differential rendering
private previousRenderCommands: RenderCommand[] = [];
private previousLines: string[] = []; // What we rendered last time
// Performance metrics
private totalLinesRedrawn = 0;
private renderCount = 0;
public getLinesRedrawn(): number {
return this.totalLinesRedrawn;
}
public getAverageLinesRedrawn(): number {
return this.renderCount > 0 ? this.totalLinesRedrawn / this.renderCount : 0;
}
constructor(terminal?: Terminal) {
super();
this.setTui(this);
this.handleResize = this.handleResize.bind(this);
this.handleKeypress = this.handleKeypress.bind(this);
// Use provided terminal or default to ProcessTerminal
this.terminal = terminal || new ProcessTerminal();
logger.componentLifecycle("TUI", "created");
}
@ -234,41 +171,20 @@ export class TUI extends Container {
logger.info("TUI", "Logging configured", config);
}
override addChild(component: Element): void {
// Set parent TUI reference for containers
if (component instanceof Container) {
component.setParentTui(this);
}
super.addChild(component);
// Only auto-render if TUI has been started
if (this.isStarted) {
this.requestRender();
}
}
override removeChild(component: Element): void {
super.removeChild(component);
this.requestRender();
}
setFocus(component: Component): void {
// Check if component exists anywhere in the hierarchy
if (this.findComponent(component)) {
this.focusedComponent = component;
}
}
private findComponent(component: Component): boolean {
// Check direct children
if (this.children.includes(component)) {
return true;
}
// Recursively search in containers
for (const comp of this.children) {
if (comp instanceof Container) {
if (this.findInContainer(comp, component)) {
for (const child of this.children) {
if (child instanceof Container) {
if (this.findInContainer(child, component)) {
return true;
}
}
@ -280,17 +196,11 @@ export class TUI extends Container {
private findInContainer(container: Container, component: Component): boolean {
const childCount = container.getChildCount();
// Check direct children
for (let i = 0; i < childCount; i++) {
const child = container.getChild(i);
if (child === component) {
return true;
}
}
// Recursively search in nested containers
for (let i = 0; i < childCount; i++) {
const child = container.getChild(i);
if (child instanceof Container) {
if (this.findInContainer(child, component)) {
return true;
@ -303,37 +213,30 @@ export class TUI extends Container {
requestRender(): void {
if (!this.isStarted) return;
this.needsRender = true;
// Batch renders on next tick
process.nextTick(() => {
if (this.needsRender) {
this.renderToScreen();
this.needsRender = false;
}
});
// Only queue a render if we haven't already
if (!this.needsRender) {
this.needsRender = true;
process.nextTick(() => {
if (this.needsRender) {
this.renderToScreen();
this.needsRender = false;
}
});
}
}
start(): void {
// Set started flag
this.isStarted = true;
// Hide the terminal cursor
process.stdout.write("\x1b[?25l");
// Hide cursor
this.terminal.write("\x1b[?25l");
// Set up raw mode for key capture
// Start terminal with handlers
try {
this.wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
// Listen for events
process.stdout.on("resize", this.handleResize);
process.stdin.on("data", this.handleKeypress);
this.terminal.start(this.handleKeypress, this.handleResize);
} catch (error) {
console.error("Error setting up raw mode:", error);
console.error("Error starting terminal:", error);
}
// Initial render
@ -341,127 +244,261 @@ export class TUI extends Container {
}
stop(): void {
// Show the terminal cursor again
process.stdout.write("\x1b[?25h");
// Show cursor
this.terminal.write("\x1b[?25h");
process.stdin.removeListener("data", this.handleKeypress);
process.stdout.removeListener("resize", this.handleResize);
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
// Stop terminal
this.terminal.stop();
this.isStarted = false;
}
private renderToScreen(resize = false): void {
const termWidth = this.terminal.columns;
const termHeight = this.terminal.rows;
if (resize) {
this.isFirstRender = true;
this.previousRenderCommands = [];
this.previousLines = [];
}
// Collect all render commands
const currentRenderCommands: RenderCommand[] = [];
this.collectRenderCommands(this, termWidth, currentRenderCommands);
if (this.isFirstRender) {
this.executeInitialRender(currentRenderCommands);
this.isFirstRender = false;
} else {
this.executeDifferentialRender(currentRenderCommands, termHeight);
}
// Save for next render
this.previousRenderCommands = currentRenderCommands;
this.renderCount++;
}
private collectRenderCommands(container: Container, width: number, commands: RenderCommand[]): void {
const childCount = container.getChildCount();
for (let i = 0; i < childCount; i++) {
const child = container.getChild(i);
if (!child) continue;
const result = child.render(width);
commands.push({
id: child.id,
lines: result.lines,
changed: result.changed,
});
}
}
private renderToScreen(resize: boolean = false): void {
const termWidth = process.stdout.columns || 80;
private executeInitialRender(commands: RenderCommand[]): void {
let output = "";
const lines: string[] = [];
logger.debug("TUI", "Starting render cycle", {
termWidth,
componentCount: this.children.length,
isFirstRender: this.isFirstRender,
});
const result = this.render(termWidth);
if (resize) {
this.totalLines = result.lines.length;
result.keepLines = 0;
this.isFirstRender = true;
for (const command of commands) {
lines.push(...command.lines);
}
logger.debug("TUI", "Render result", {
totalLines: result.lines.length,
keepLines: result.keepLines,
changed: result.changed,
previousTotalLines: this.totalLines,
});
if (!result.changed) {
// Nothing changed - skip render
return;
// Output all lines
for (let i = 0; i < lines.length; i++) {
if (i > 0) output += "\r\n";
output += lines[i];
}
// Handle cursor positioning
if (this.isFirstRender) {
// First render: just append to current terminal position
this.isFirstRender = false;
// Output all lines normally on first render
for (const line of result.lines) {
console.log(line);
// Add final newline to position cursor below content
if (lines.length > 0) output += "\r\n";
this.terminal.write(output);
// Save what we rendered
this.previousLines = lines;
this.totalLinesRedrawn += lines.length;
logger.debug("TUI", "Initial render", {
commandsExecuted: commands.length,
linesRendered: lines.length,
});
}
private executeDifferentialRender(currentCommands: RenderCommand[], termHeight: number): void {
let output = "";
let linesRedrawn = 0;
const viewportHeight = termHeight - 1; // Leave one line for cursor
// Build the new lines
const newLines: string[] = [];
for (const command of currentCommands) {
newLines.push(...command.lines);
}
// Calculate total lines for both old and new
const totalNewLines = newLines.length;
const totalOldLines = this.previousLines.length;
// Calculate what's visible in viewport
const oldVisibleLines = Math.min(totalOldLines, viewportHeight);
const newVisibleLines = Math.min(totalNewLines, viewportHeight);
// Check if we need to do a full redraw
let needFullRedraw = false;
let currentLineOffset = 0;
// Compare commands to detect structural changes
for (let i = 0; i < currentCommands.length; i++) {
const current = currentCommands[i];
const previous = i < this.previousRenderCommands.length ? this.previousRenderCommands[i] : null;
// Check if component order changed or new component
if (!previous || previous.id !== current.id) {
needFullRedraw = true;
break;
}
// Check if component changed
if (current.changed) {
// Check if line count changed
if (current.lines.length !== previous.lines.length) {
needFullRedraw = true;
break;
}
// Check if component is fully visible
const componentEnd = currentLineOffset + current.lines.length;
const visibleStart = Math.max(0, totalNewLines - viewportHeight);
if (currentLineOffset < visibleStart) {
// Component is partially or fully outside viewport
needFullRedraw = true;
break;
}
}
currentLineOffset += current.lines.length;
}
// Move cursor to top of our content
if (oldVisibleLines > 0) {
output += `\x1b[${oldVisibleLines}A`;
}
if (needFullRedraw) {
// Clear each old line to avoid wrapping artifacts
for (let i = 0; i < oldVisibleLines; i++) {
if (i > 0) output += `\x1b[1B`; // Move down one line
output += "\x1b[2K"; // Clear entire line
}
// Move back to start position
if (oldVisibleLines > 1) {
output += `\x1b[${oldVisibleLines - 1}A`;
}
// Ensure cursor is at beginning of line
output += "\r";
// Clear any remaining lines
output += "\x1b[0J"; // Clear from cursor to end of screen
// Determine what to render
let linesToRender: string[];
if (totalNewLines <= viewportHeight) {
// Everything fits - render all
linesToRender = newLines;
} else {
// Only render what fits in viewport (last N lines)
linesToRender = newLines.slice(-viewportHeight);
}
// Output the lines
for (let i = 0; i < linesToRender.length; i++) {
if (i > 0) output += "\r\n";
output += linesToRender[i];
}
// Add final newline
if (linesToRender.length > 0) output += "\r\n";
linesRedrawn = linesToRender.length;
} else {
// Move cursor up to start of changing content and clear down
const linesToMoveUp = this.totalLines - result.keepLines;
let output = "";
// Do line-by-line diff for visible portion only
const oldVisible =
totalOldLines > viewportHeight ? this.previousLines.slice(-viewportHeight) : this.previousLines;
const newVisible = totalNewLines > viewportHeight ? newLines.slice(-viewportHeight) : newLines;
logger.debug("TUI", "Cursor movement", {
linesToMoveUp,
totalLines: this.totalLines,
keepLines: result.keepLines,
changingLineCount: result.lines.length - result.keepLines,
});
// Compare and update only changed lines
const maxLines = Math.max(oldVisible.length, newVisible.length);
if (linesToMoveUp > 0) {
output += `\x1b[${linesToMoveUp}A\x1b[0J`;
for (let i = 0; i < maxLines; i++) {
const oldLine = i < oldVisible.length ? oldVisible[i] : "";
const newLine = i < newVisible.length ? newVisible[i] : "";
if (i >= newVisible.length) {
// This line no longer exists - clear it
if (i > 0) {
output += `\x1b[${i}B`; // Move to line i
}
output += "\x1b[2K"; // Clear line
output += `\x1b[${i}A`; // Move back to top
} else if (oldLine !== newLine) {
// Line changed - update it
if (i > 0) {
output += `\x1b[${i}B`; // Move to line i
}
output += "\x1b[2K\r"; // Clear line and return to start
output += newLine;
if (i > 0) {
output += `\x1b[${i}A`; // Move back to top
}
linesRedrawn++;
}
}
// Build the output string for all changing lines
const changingLines = result.lines.slice(result.keepLines);
// Move cursor to end
output += `\x1b[${newVisible.length}B`;
logger.debug("TUI", "Output details", {
linesToMoveUp,
changingLinesCount: changingLines.length,
keepLines: result.keepLines,
totalLines: result.lines.length,
previousTotalLines: this.totalLines,
});
for (const line of changingLines) {
output += `${line}\n`;
// Clear any remaining lines if we have fewer lines now
if (newVisible.length < oldVisible.length) {
output += "\x1b[0J";
}
// Write everything at once - use synchronous write to prevent race conditions
writeSync(process.stdout.fd, output);
}
this.totalLines = result.lines.length;
this.terminal.write(output);
// Clean up sentinels after rendering
this.cleanupSentinels();
// Save what we rendered
this.previousLines = newLines;
this.totalLinesRedrawn += linesRedrawn;
logger.debug("TUI", "Differential render", {
linesRedrawn,
needFullRedraw,
totalNewLines,
totalOldLines,
});
}
private handleResize(): void {
// Clear screen, hide cursor, and reset color
process.stdout.write("\u001Bc\x1b[?25l\u001B[3J");
// Terminal size changed - force re-render all
// Clear screen and reset
this.terminal.write("\x1b[2J\x1b[H\x1b[?25l");
this.renderToScreen(true);
}
private handleKeypress(data: string): void {
logger.keyInput("TUI", data);
// Don't handle Ctrl+C here - let the global key handler deal with it
// if (data.charCodeAt(0) === 3) {
// logger.info("TUI", "Ctrl+C received");
// return; // Don't process this key further
// }
// Call global key handler if set
if (this.onGlobalKeyPress) {
const shouldForward = this.onGlobalKeyPress(data);
if (!shouldForward) {
// Global handler consumed the key, don't forward to focused component
this.requestRender();
return;
}
}
// Send input to focused component
if (this.focusedComponent?.handleInput) {
logger.debug("TUI", "Forwarding input to focused component", {
componentType: this.focusedComponent.constructor.name,
});
this.focusedComponent.handleInput(data);
// Trigger re-render after input
this.requestRender();
} else {
logger.warn("TUI", "No focused component to handle input", {

115
packages/tui/test/bench.ts Normal file
View 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);
});

View 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();
});
});

View 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();
});
});

View 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
};
}
}