mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +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
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -644,6 +644,20 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/headless": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
|
||||
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz",
|
||||
|
|
@ -781,7 +795,7 @@
|
|||
"version": "0.5.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-tui": "^0.5.6",
|
||||
"@mariozechner/pi-tui": "^0.5.7",
|
||||
"@types/glob": "^8.1.0",
|
||||
"chalk": "^5.5.0",
|
||||
"glob": "^11.0.3",
|
||||
|
|
@ -1225,7 +1239,7 @@
|
|||
"version": "0.5.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.5.6",
|
||||
"@mariozechner/pi-agent": "^0.5.7",
|
||||
"chalk": "^5.5.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -1246,7 +1260,10 @@
|
|||
"marked": "^15.0.12",
|
||||
"mime-types": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"devDependencies": {
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"@xterm/xterm": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
packages/tui/src/components/loading-animation.ts
Normal file
51
packages/tui/src/components/loading-animation.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] = [];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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 " │"
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
72
packages/tui/src/terminal.ts
Normal file
72
packages/tui/src/terminal.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
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
|
||||
};
|
||||
}
|
||||
}
|
||||
132
todos/done/2025-08-10-14-40-44-tui-double-buffer-analysis.md
Normal file
132
todos/done/2025-08-10-14-40-44-tui-double-buffer-analysis.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# TUI Double Buffer Implementation Analysis
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Core TUI Rendering System
|
||||
- **Location:** `/Users/badlogic/workspaces/pi-mono/packages/tui/src/tui.ts`
|
||||
- **render()** method (lines 107-150): Traverses components, calculates keepLines
|
||||
- **renderToScreen()** method (lines 354-429): Outputs to terminal with differential rendering
|
||||
- **Terminal output:** Single `writeSync()` call at line 422
|
||||
|
||||
### Component Interface
|
||||
```typescript
|
||||
interface ComponentRenderResult {
|
||||
lines: string[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
interface ContainerRenderResult extends ComponentRenderResult {
|
||||
keepLines: number; // Lines from top that are unchanged
|
||||
}
|
||||
```
|
||||
|
||||
### The Flicker Problem
|
||||
|
||||
**Root Cause:**
|
||||
1. LoadingAnimation (`packages/agent/src/renderers/tui-renderer.ts`) updates every 80ms
|
||||
2. Calls `ui.requestRender()` on each frame, marking itself as changed
|
||||
3. Container's `keepLines` logic stops accumulating once any child changes
|
||||
4. All components below animation must re-render completely
|
||||
5. TextEditor always returns `changed: true` for cursor updates
|
||||
|
||||
**Current Differential Rendering:**
|
||||
- Moves cursor up by `(totalLines - keepLines)` lines
|
||||
- Clears everything from cursor down with `\x1b[0J`
|
||||
- Writes all lines after `keepLines` position
|
||||
- Creates visible flicker when large portions re-render
|
||||
|
||||
### Performance Bottlenecks
|
||||
|
||||
1. **TextEditor (`packages/tui/src/text-editor.ts`):**
|
||||
- Always returns `changed: true` (lines 122-125)
|
||||
- Complex `layoutText()` recalculates wrapping every render
|
||||
- Heavy computation for cursor positioning and highlighting
|
||||
|
||||
2. **Animation Cascade Effect:**
|
||||
- Single animated component forces all components below to re-render
|
||||
- Container stops accumulating `keepLines` after first change
|
||||
- No isolation between independent component updates
|
||||
|
||||
3. **Terminal I/O:**
|
||||
- Single large `writeSync()` call for all changing content
|
||||
- Clears and redraws entire sections even for minor changes
|
||||
|
||||
### Existing Optimizations
|
||||
|
||||
**Component Caching:**
|
||||
- TextComponent: Stores `lastRenderedLines[]`, compares arrays
|
||||
- MarkdownComponent: Uses `previousLines[]` comparison
|
||||
- WhitespaceComponent: `firstRender` flag
|
||||
- Components properly detect and report changes
|
||||
|
||||
**Render Batching:**
|
||||
- `requestRender()` uses `process.nextTick()` to batch updates
|
||||
- Prevents multiple renders in same tick
|
||||
|
||||
## Double Buffer Solution
|
||||
|
||||
### Architecture Benefits
|
||||
- Components already return `{lines, changed}` - no interface changes needed
|
||||
- Clean separation between rendering (back buffer) and output (terminal)
|
||||
- Single `writeSync()` location makes implementation straightforward
|
||||
- Existing component caching remains useful
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
**TuiDoubleBuffer Class:**
|
||||
1. Extend current TUI class
|
||||
2. Maintain front buffer (last rendered lines) and back buffer (new render)
|
||||
3. Override `renderToScreen()` with line-by-line diffing algorithm
|
||||
4. Batch consecutive changed lines to minimize writeSync() calls
|
||||
5. Position cursor only at changed lines, not entire sections
|
||||
|
||||
**Line-Level Diffing Algorithm:**
|
||||
```typescript
|
||||
// Pseudocode
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
if (frontBuffer[i] !== backBuffer[i]) {
|
||||
// Position cursor at line i
|
||||
// Clear line
|
||||
// Write new content
|
||||
// Or batch with adjacent changes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Expected Benefits
|
||||
|
||||
1. **Reduced Flicker:**
|
||||
- Only changed lines are redrawn
|
||||
- Animation updates don't affect static content below
|
||||
- TextEditor cursor updates don't require full redraw
|
||||
|
||||
2. **Better Performance:**
|
||||
- Fewer terminal control sequences
|
||||
- Smaller writeSync() payloads
|
||||
- Components can cache aggressively
|
||||
|
||||
3. **Preserved Functionality:**
|
||||
- No changes to existing components
|
||||
- Backward compatible with current TUI class
|
||||
- Can switch between single/double buffer modes
|
||||
|
||||
### Test Plan
|
||||
|
||||
Create comparison tests:
|
||||
1. `packages/tui/test/single-buffer.ts` - Current implementation
|
||||
2. `packages/tui/test/double-buffer.ts` - New implementation
|
||||
3. Both with LoadingAnimation above TextEditor
|
||||
4. Measure render() timing and visual flicker
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**New Files:**
|
||||
- `packages/tui/src/tui-double-buffer.ts` - New TuiDoubleBuffer class
|
||||
|
||||
**Test Files:**
|
||||
- `packages/tui/test/single-buffer.ts` - Test current implementation
|
||||
- `packages/tui/test/double-buffer.ts` - Test new implementation
|
||||
|
||||
**No Changes Needed:**
|
||||
- Component implementations (already support caching and change detection)
|
||||
- Component interfaces (already return required data)
|
||||
92
todos/done/2025-08-10-14-40-44-tui-double-buffer.md
Normal file
92
todos/done/2025-08-10-14-40-44-tui-double-buffer.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# TUI Double Buffer Implementation
|
||||
|
||||
**Status:** Done
|
||||
**Agent PID:** 74014
|
||||
|
||||
## Original Todo
|
||||
- tui: we get tons of flicker in the text editor component. specifically, if we have an animated component above the editor, the editor needs re-rendering completely. Different strategy:
|
||||
- keep a back buffer and front buffer. a buffer is a list of lines.
|
||||
- on Tui.render()
|
||||
- render a new back buffer, top to bottom. components can cache previous render results and return that as a single list of lines if nothing changed
|
||||
- compare the back buffer with the front buffer. for each line that changed
|
||||
- position the cursor at that line
|
||||
- clear the line
|
||||
- render the new line
|
||||
- batch multiple subsequent lines that changed so we do not have tons of writeSync() calls
|
||||
- Open questions:
|
||||
- is this faster and procudes less flicker?
|
||||
- If possible, we should implement this as a new TuiDoubleBuffer class. Existing components should not need changing, as they already report if they changed and report their lines
|
||||
- Testing:
|
||||
- Create a packages/tui/test/single-buffer.ts file: it has a LoadingAnimation like in packages/agent/src/renderers/tui-renderer.ts inside a container as the first child, and a text editor component as the second child, which is focused.
|
||||
- Create a packages/tui/test/double-buffer.ts file: same setup
|
||||
- Measure timing of render() for both
|
||||
|
||||
## Description
|
||||
Implement a double-buffering strategy for the TUI rendering system to eliminate flicker when animated components (like LoadingAnimation) are displayed above interactive components (like TextEditor). The solution will use line-by-line diffing between a front buffer (previous render) and back buffer (current render) to only update changed lines on the terminal, replacing the current section-based differential rendering.
|
||||
|
||||
*Read [analysis.md](./analysis.md) in full for detailed codebase research and context*
|
||||
|
||||
## Implementation Plan
|
||||
- [x] Create TuiDoubleBuffer class extending Container with same interface as TUI (`packages/tui/src/tui-double-buffer.ts`)
|
||||
- [x] Implement line-by-line diffing algorithm in overridden renderToScreen() method
|
||||
- [x] Add batching logic to group consecutive changed lines for efficient terminal writes
|
||||
- [x] Create test file with current single-buffer implementation (`packages/tui/test/single-buffer.ts`)
|
||||
- [x] Create test file with new double-buffer implementation (`packages/tui/test/double-buffer.ts`)
|
||||
- [x] Add timing measurements to both test files to compare performance
|
||||
- [x] Manual test: Run both test files to verify reduced flicker in double-buffer version
|
||||
- [x] Manual test: Verify existing TUI functionality still works with original class
|
||||
- [x] Fix cursor positioning bug in double-buffer implementation (stats appear at top, components don't update)
|
||||
- [x] Add write function parameter to both TUI classes for testability
|
||||
- [x] Create VirtualTerminal class for testing ANSI output
|
||||
- [x] Create verification test that compares both implementations
|
||||
- [x] Redesign double-buffer with proper cursor tracking to fix duplicate content issue
|
||||
- [x] Implement component-based rendering with unique IDs to handle reordering
|
||||
|
||||
## Additional Work Completed
|
||||
|
||||
### Terminal Abstraction & Testing Infrastructure
|
||||
- [x] Created Terminal interface abstracting stdin/stdout operations (`packages/tui/src/terminal.ts`)
|
||||
- [x] Implemented ProcessTerminal for production use with process.stdin/stdout
|
||||
- [x] Implemented VirtualTerminal using @xterm/headless for accurate terminal emulation in tests
|
||||
- [x] Fixed @xterm/headless TypeScript imports (changed from wildcard to proper named imports)
|
||||
- [x] Added test-specific methods to VirtualTerminal (flushAndGetViewport, writeSync)
|
||||
- [x] Updated TUI class to accept Terminal interface via constructor for dependency injection
|
||||
|
||||
### Component Organization
|
||||
- [x] Moved all component files to `packages/tui/src/components/` directory
|
||||
- [x] Updated all imports in index.ts and test files to use new paths
|
||||
|
||||
### Test Suite Updates
|
||||
- [x] Created comprehensive test suite for VirtualTerminal (`packages/tui/test/virtual-terminal.test.ts`)
|
||||
- [x] Updated TUI rendering tests to use async/await pattern for proper render timing
|
||||
- [x] Fixed all test assertions to work with exact output (no trim() allowed per user requirement)
|
||||
- [x] Fixed xterm newline handling (discovered \r\n requirement vs just \n)
|
||||
- [x] Added test for preserving existing terminal content when TUI starts and handles component growth
|
||||
|
||||
### Build Configuration
|
||||
- [x] Updated root tsconfig.json to include test files for type checking
|
||||
- [x] Ensured monorepo-wide type checking covers all source and test files
|
||||
|
||||
### Bug Fixes
|
||||
- [x] Fixed 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
|
||||
- This prevents line-wrapping artifacts when the text editor grows (e.g., SHIFT+ENTER adding lines)
|
||||
|
||||
## Notes
|
||||
- Successfully implemented TuiDoubleBuffer class with line-by-line diffing
|
||||
- Complete redesign with proper cursor tracking:
|
||||
- Tracks actual cursor position separately from buffer length
|
||||
- Clear separation between screenBuffer and new render
|
||||
- Removed console.log/stdout.write interceptors per user request
|
||||
- Terminal abstraction enables proper testing without mocking process.stdin/stdout
|
||||
- VirtualTerminal provides accurate terminal emulation using xterm.js
|
||||
- Test results show significant reduction in flicker:
|
||||
- Single-buffer: Uses clear-down (`\x1b[0J`) which clears entire sections
|
||||
- Double-buffer: Uses clear-line (`\x1b[2K`) only for changed lines
|
||||
- Animation updates only affect the animation line, not the editor below
|
||||
- Performance similar between implementations (~0.4-0.6ms per render)
|
||||
- Both TUI and TuiDoubleBuffer maintain the same interface for backward compatibility
|
||||
- Can be used as drop-in replacement: just change `new TUI()` to `new TuiDoubleBuffer()`
|
||||
- All 22 tests passing with proper async handling and exact output matching
|
||||
- Fixed critical rendering bug in TUI's differential rendering for growing components
|
||||
|
|
@ -1,20 +1,3 @@
|
|||
- tui: we get tons of flicker in the text editor component. specifically, if we have an animated component above the editor, the editor needs re-rendering completely. Different strategy:
|
||||
- keep a back buffer and front buffer. a buffer is a list of lines.
|
||||
- on Tui.render()
|
||||
- render a new back buffer, top to bottom. components can cache previous render results and return that as a single list of lines if nothing changed
|
||||
- compare the back buffer with the front buffer. for each line that changed
|
||||
- position the cursor at that line
|
||||
- clear the line
|
||||
- render the new line
|
||||
- batch multiple subsequent lines that changed so we do not have tons of writeSync() calls
|
||||
- Open questions:
|
||||
- is this faster and procudes less flicker?
|
||||
- If possible, we should implement this as a new TuiDoubleBuffer class. Existing components should not need changing, as they already report if they changed and report their lines
|
||||
- Testing:
|
||||
- Create a packages/tui/test/single-buffer.ts file: it has a LoadingAnimation like in packages/agent/src/renderers/tui-renderer.ts inside a container as the first child, and a text editor component as the second child, which is focused.
|
||||
- Create a packages/tui/test/double-buffer.ts file: same setup
|
||||
- Measure timing of render() for both
|
||||
|
||||
- agent: improve reasoning section in README.md
|
||||
|
||||
- agent: ultrathink to temporarily set reasoning_effort?
|
||||
|
|
@ -44,7 +27,7 @@
|
|||
|
||||
- agent: token usage gets overwritten with each message that has usage data. however, if the latest data doesn't have a specific usage field, we record undefined i think? also, {"type":"token_usage" "inputTokens":240,"outputTokens":35,"totalTokens":275,"cacheReadTokens":0,"cacheWriteTokens":0} doesn't contain reasoningToken? do we lack initialization? See case "token_usage": in renderers. probably need to check if lastXXX > current and use lastXXX.
|
||||
|
||||
-agent: groq responses api throws on second message
|
||||
- agent: groq responses api throws on second message
|
||||
```
|
||||
➜ pi-mono git:(main) ✗ npx tsx packages/agent/src/cli.ts --base-url https://api.groq.com/openai/v1 --api-key $GROQ_API_KEY --model openai/gpt-oss-120b --api responses
|
||||
>> pi interactive chat <<<
|
||||
|
|
@ -82,6 +65,4 @@
|
|||
|
||||
- agent: start a new agent session. when i press CTRL+C, "Press Ctrl+C again to exit" appears above the text editor followed by an empty line. After about 1 second, the empty line disappears. We should either not show the empty line, or always show the empty line. Maybe Ctrl+C info should be displayed below the text editor.
|
||||
|
||||
- tui: npx tsx test/demo.ts, using /exit or pressing CTRL+C does not work to exit the demo.
|
||||
|
||||
- agent: we need to make system prompt and tools pluggable. We need to figure out the simplest way for users to define system prompts and toolkits. A toolkit could be a subset of the built-in tools, a mixture of a subset of the built-in tools plus custom self-made tools, maybe include MCP servers, and so on. We need to figure out a way to make this super easy. users should be able to write their tools in whatever language they fancy. which means that probably something like process spawning plus studio communication transport would make the most sense. but then we were back at MCP basically. And that does not support interruptibility, which we need for the agent. So if the agent invokes the tool and the user presses escape in the interface, then the tool invocation must be interrupted and whatever it's doing must stop, including killing all sub-processes. For MCP this could be solved for studio MCP servers by, since we spawn those on startup or whenever we load the tools, we spawn a process for an MCP server and then reuse that process for subsequent tool invocations. If the user interrupts then we could just kill that process, assuming that anything it's doing or any of its sub-processes will be killed along the way. So I guess tools could all be written as MCP servers, but that's a lot of overhead. It would also be nice to be able to provide tools just as a bash script that gets some inputs and return some outputs based on the inputs Same for Go apps or TypeScript apps invoked by MPX TSX. just make the barrier of entry for writing your own tools super fucking low. not necessarily going full MCP. but we also need to support MCP. So whatever we arrive at, we then need to take our built-in tools and see if those can be refactored to work with our new tools
|
||||
|
|
@ -7,5 +7,5 @@
|
|||
"@mariozechner/pi": ["./packages/pods/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["packages/*/src/**/*"]
|
||||
"include": ["packages/*/src/**/*", "packages/*/test/**/*"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue