Clean up TUI package and refactor component structure

- Remove old TUI implementation and components (LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, WhitespaceComponent)
- Rename components-new to components with new API (Loader, Markdown, Text, Editor, Spacer)
- Move Text and Input components to separate files in src/components/
- Add render caching to Text component (similar to Markdown)
- Add proper ANSI code handling in Text component using stripVTControlCharacters
- Update coding-agent to use new TUI API (requires ProcessTerminal, uses custom Editor subclass for key handling)
- Remove old test files, keep only chat-simple.ts and virtual-terminal.ts
- Update README.md with new minimal API documentation
- Switch from tsc to tsgo for type checking
- Update package dependencies across monorepo
This commit is contained in:
Mario Zechner 2025-11-11 10:32:18 +01:00
parent 1caa3cc1a7
commit 985f955ea0
40 changed files with 998 additions and 4516 deletions

View file

@ -1,119 +0,0 @@
#!/usr/bin/env npx tsx
import chalk from "chalk";
import { Container, LoadingAnimation, TextComponent, TextEditor, TUI, WhitespaceComponent } from "../src/index.js";
/**
* 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 = () => {
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

@ -1,114 +0,0 @@
#!/usr/bin/env npx tsx
import {
CombinedAutocompleteProvider,
Container,
MarkdownComponent,
TextComponent,
TextEditor,
TUI,
} from "../src/index.js";
/**
* Chat Application with Autocomplete
*
* Demonstrates:
* - Slash command system with autocomplete
* - Dynamic message history
* - Markdown rendering for messages
* - Container-based layout
*/
const ui = new TUI();
// Add header with instructions
const header = new TextComponent(
"💬 Chat Demo | Type '/' for commands | Start typing a filename + Tab to autocomplete | Ctrl+C to exit",
{ bottom: 1 },
);
const chatHistory = new Container();
const editor = new TextEditor();
// Set up autocomplete with slash commands
const autocompleteProvider = new CombinedAutocompleteProvider([
{ name: "clear", description: "Clear chat history" },
{ name: "help", description: "Show help information" },
{
name: "attach",
description: "Attach a file",
getArgumentCompletions: () => {
// Return file suggestions for attach command
return null; // Use default file completion
},
},
]);
editor.setAutocompleteProvider(autocompleteProvider);
editor.onSubmit = (text) => {
// Handle slash commands
if (text.startsWith("/")) {
const [command, ...args] = text.slice(1).split(" ");
if (command === "clear") {
chatHistory.clear();
return;
}
if (command === "help") {
const help = new MarkdownComponent(`
## Available Commands
- \`/clear\` - Clear chat history
- \`/help\` - Show this help
- \`/attach <file>\` - Attach a file
`);
chatHistory.addChild(help);
ui.requestRender();
return;
}
}
// Regular message
const message = new MarkdownComponent(`**You:** ${text}`);
chatHistory.addChild(message);
// Add AI response (simulated)
setTimeout(() => {
const response = new MarkdownComponent(`**AI:** Response to "${text}"`);
chatHistory.addChild(response);
ui.requestRender();
}, 1000);
};
// Handle Ctrl+C to exit
ui.onGlobalKeyPress = (data: string) => {
if (data === "\x03") {
ui.stop();
console.log("\nChat application exited");
process.exit(0);
}
return true;
};
// Add initial welcome message to chat history
chatHistory.addChild(
new MarkdownComponent(`
## Welcome to the Chat Demo!
**Available slash commands:**
- \`/clear\` - Clear the chat history
- \`/help\` - Show help information
- \`/attach <file>\` - Attach a file (with autocomplete)
**File autocomplete:**
- Start typing any filename or directory name and press **Tab**
- Works with relative paths (\`./\`, \`../\`)
- Works with home directory (\`~/\`)
Try it out! Type a message or command below.
`),
);
ui.addChild(header);
ui.addChild(chatHistory);
ui.addChild(editor);
ui.setFocus(editor);
ui.start();

View file

@ -1,48 +0,0 @@
/**
* Debug version of chat-simple with logging
*/
import fs from "fs";
import { ProcessTerminal } from "../src/terminal.js";
import { Input, Text, TUI } from "../src/tui-new.js";
// Clear debug log
fs.writeFileSync("debug.log", "");
function log(msg: string) {
fs.appendFileSync("debug.log", msg + "\n");
}
// Create terminal
const terminal = new ProcessTerminal();
// Wrap terminal methods to log
const originalWrite = terminal.write.bind(terminal);
const originalMoveBy = terminal.moveBy.bind(terminal);
terminal.write = (data: string) => {
log(`WRITE: ${JSON.stringify(data)}`);
originalWrite(data);
};
terminal.moveBy = (lines: number) => {
log(`MOVEBY: ${lines}`);
originalMoveBy(lines);
};
// Create TUI
const tui = new TUI(terminal);
// Create chat container with some initial messages
tui.addChild(new Text("Welcome to Simple Chat!"));
tui.addChild(new Text("Type your messages below. Press Ctrl+C to exit.\n"));
// Create input field
const input = new Input();
tui.addChild(input);
// Focus the input
tui.setFocus(input);
// Start the TUI
tui.start();

View file

@ -1,13 +1,14 @@
/**
* Simple chat interface demo using tui-new.ts
* Simple chat interface demo using tui.ts
*/
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
import { Editor } from "../src/components-new/editor.js";
import { Loader } from "../src/components-new/loader.js";
import { Markdown } from "../src/components-new/markdown.js";
import { Editor } from "../src/components/editor.js";
import { Loader } from "../src/components/loader.js";
import { Markdown } from "../src/components/markdown.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { Text, TUI } from "../src/tui-new.js";
import { TUI } from "../src/tui.js";
// Create terminal
const terminal = new ProcessTerminal();
@ -16,8 +17,9 @@ const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
// Create chat container with some initial messages
tui.addChild(new Text("Welcome to Simple Chat!"));
tui.addChild(new Text("Type your messages below. Type '/' for commands. Press Ctrl+C to exit.\n"));
tui.addChild(
new Text("Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit."),
);
// Create editor with autocomplete
const editor = new Editor();

View file

@ -1,203 +0,0 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Container, TextComponent, TextEditor, TUI } from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("Differential Rendering - Dynamic Content", () => {
test("handles static text, dynamic container, and text editor correctly", async () => {
const terminal = new VirtualTerminal(80, 10); // Small viewport to test scrolling
const ui = new TUI(terminal);
ui.start();
// Step 1: Add a static text component
const staticText = new TextComponent("Static Header Text");
ui.addChild(staticText);
// Step 2: Add an initially empty container
const container = new Container();
ui.addChild(container);
// Step 3: Add a text editor field
const editor = new TextEditor();
ui.addChild(editor);
ui.setFocus(editor);
// Wait for next tick to complete and flush virtual terminal
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 4: Check initial output in scrollbuffer
let scrollBuffer = terminal.getScrollBuffer();
let viewport = terminal.getViewport();
console.log("Initial render:");
console.log("Viewport lines:", viewport.length);
console.log("ScrollBuffer lines:", scrollBuffer.length);
// Count non-empty lines in scrollbuffer
const nonEmptyInBuffer = scrollBuffer.filter((line) => line.trim() !== "").length;
console.log("Non-empty lines in scrollbuffer:", nonEmptyInBuffer);
// Verify initial render has static text in scrollbuffer
assert.ok(
scrollBuffer.some((line) => line.includes("Static Header Text")),
`Expected static text in scrollbuffer`,
);
// Step 5: Add 100 text components to container
console.log("\nAdding 100 components to container...");
for (let i = 1; i <= 100; i++) {
container.addChild(new TextComponent(`Dynamic Item ${i}`));
}
// Request render after adding all components
ui.requestRender();
// Wait for next tick to complete and flush
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 6: Check output after adding 100 components
scrollBuffer = terminal.getScrollBuffer();
viewport = terminal.getViewport();
console.log("\nAfter adding 100 items:");
console.log("Viewport lines:", viewport.length);
console.log("ScrollBuffer lines:", scrollBuffer.length);
// Count all dynamic items in scrollbuffer
let dynamicItemsInBuffer = 0;
const allItemNumbers = new Set<number>();
for (const line of scrollBuffer) {
const match = line.match(/Dynamic Item (\d+)/);
if (match) {
dynamicItemsInBuffer++;
allItemNumbers.add(parseInt(match[1], 10));
}
}
console.log("Dynamic items found in scrollbuffer:", dynamicItemsInBuffer);
console.log("Unique item numbers:", allItemNumbers.size);
console.log("Item range:", Math.min(...allItemNumbers), "-", Math.max(...allItemNumbers));
// CRITICAL TEST: The scrollbuffer should contain ALL 100 items
// This is what the differential render should preserve!
assert.strictEqual(
allItemNumbers.size,
100,
`Expected all 100 unique items in scrollbuffer, but found ${allItemNumbers.size}`,
);
// Verify items are 1-100
for (let i = 1; i <= 100; i++) {
assert.ok(allItemNumbers.has(i), `Missing Dynamic Item ${i} in scrollbuffer`);
}
// Also verify the static header is still in scrollbuffer
assert.ok(
scrollBuffer.some((line) => line.includes("Static Header Text")),
"Static header should still be in scrollbuffer",
);
// And the editor should be there too
assert.ok(
scrollBuffer.some((line) => line.includes("╭") && line.includes("╮")),
"Editor top border should be in scrollbuffer",
);
assert.ok(
scrollBuffer.some((line) => line.includes("╰") && line.includes("╯")),
"Editor bottom border should be in scrollbuffer",
);
ui.stop();
});
test("differential render correctly updates only changed components", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
// Create multiple containers with different content
const header = new TextComponent("=== Application Header ===");
const statusContainer = new Container();
const contentContainer = new Container();
const footer = new TextComponent("=== Footer ===");
ui.addChild(header);
ui.addChild(statusContainer);
ui.addChild(contentContainer);
ui.addChild(footer);
// Add initial content
statusContainer.addChild(new TextComponent("Status: Ready"));
contentContainer.addChild(new TextComponent("Content Line 1"));
contentContainer.addChild(new TextComponent("Content Line 2"));
// Initial render
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
let viewport = terminal.getViewport();
assert.strictEqual(viewport[0], "=== Application Header ===");
assert.strictEqual(viewport[1], "Status: Ready");
assert.strictEqual(viewport[2], "Content Line 1");
assert.strictEqual(viewport[3], "Content Line 2");
assert.strictEqual(viewport[4], "=== Footer ===");
// Track lines redrawn
const initialLinesRedrawn = ui.getLinesRedrawn();
// Update only the status
statusContainer.clear();
statusContainer.addChild(new TextComponent("Status: Processing..."));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
assert.strictEqual(viewport[0], "=== Application Header ===");
assert.strictEqual(viewport[1], "Status: Processing...");
assert.strictEqual(viewport[2], "Content Line 1");
assert.strictEqual(viewport[3], "Content Line 2");
assert.strictEqual(viewport[4], "=== Footer ===");
const afterStatusUpdate = ui.getLinesRedrawn();
const statusUpdateLines = afterStatusUpdate - initialLinesRedrawn;
console.log(`Lines redrawn for status update: ${statusUpdateLines}`);
// Add many items to content container
for (let i = 3; i <= 20; i++) {
contentContainer.addChild(new TextComponent(`Content Line ${i}`));
}
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
// With 24 rows - 1 for cursor = 23 visible
// We have: 1 header + 1 status + 20 content + 1 footer = 23 lines
// Should fit exactly
assert.strictEqual(viewport[0], "=== Application Header ===");
assert.strictEqual(viewport[1], "Status: Processing...");
assert.strictEqual(viewport[21], "Content Line 20");
assert.strictEqual(viewport[22], "=== Footer ===");
// Now update just one content line
const contentLine10 = contentContainer.getChild(9) as TextComponent;
contentLine10.setText("Content Line 10 - MODIFIED");
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
assert.strictEqual(viewport[11], "Content Line 10 - MODIFIED");
assert.strictEqual(viewport[0], "=== Application Header ==="); // Should be unchanged
assert.strictEqual(viewport[22], "=== Footer ==="); // Should be unchanged
ui.stop();
});
});

View file

@ -1,55 +0,0 @@
import { readdirSync, statSync } from "fs";
import { join } from "path";
import { SelectList, TUI } from "../src/index.js";
const ui = new TUI();
ui.start();
let currentPath = process.cwd();
function createFileList(path: string) {
const entries = readdirSync(path).map((entry) => {
const fullPath = join(path, entry);
const isDir = statSync(fullPath).isDirectory();
return {
value: entry,
label: entry,
description: isDir ? "directory" : "file",
};
});
// Add parent directory option
if (path !== "/") {
entries.unshift({
value: "..",
label: "..",
description: "parent directory",
});
}
return entries;
}
function showDirectory(path: string) {
ui.clear();
const entries = createFileList(path);
const fileList = new SelectList(entries, 10);
fileList.onSelect = (item) => {
if (item.value === "..") {
currentPath = join(currentPath, "..");
showDirectory(currentPath);
} else if (item.description === "directory") {
currentPath = join(currentPath, item.value);
showDirectory(currentPath);
} else {
console.log(`Selected file: ${join(currentPath, item.value)}`);
ui.stop();
}
};
ui.addChild(fileList);
ui.setFocus(fileList);
}
showDirectory(currentPath);

View file

@ -1,145 +0,0 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Container, TextComponent, TextEditor, TUI } from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("Layout shift artifacts", () => {
test("clears artifacts when components shift positions dynamically (like agent Ctrl+C)", async () => {
const term = new VirtualTerminal(80, 20);
const ui = new TUI(term);
// Simulate agent's layout: header, chat container, status container, editor
const header = new TextComponent(">> pi interactive chat <<<");
const chatContainer = new Container();
const statusContainer = new Container();
const editor = new TextEditor({ multiline: false });
// Add some chat content
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("Hello"));
chatContainer.addChild(new TextComponent("[assistant]"));
chatContainer.addChild(new TextComponent("Hi there!"));
ui.addChild(header);
ui.addChild(chatContainer);
ui.addChild(statusContainer);
ui.addChild(editor);
// Initial render
ui.start();
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture initial state
const initialViewport = term.getViewport();
// Simulate what happens when Ctrl+C is pressed (like in agent)
statusContainer.clear();
const hint = new TextComponent("Press Ctrl+C again to exit");
statusContainer.addChild(hint);
ui.requestRender();
// Wait for render
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture state with status message
const withStatusViewport = term.getViewport();
// Simulate the timeout that clears the hint (like agent does after 500ms)
statusContainer.clear();
ui.requestRender();
// Wait for render
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture final state
const finalViewport = term.getViewport();
// Check for artifacts - look for duplicate bottom borders on consecutive lines
let foundDuplicateBorder = false;
for (let i = 0; i < finalViewport.length - 1; i++) {
const currentLine = finalViewport[i];
const nextLine = finalViewport[i + 1];
// Check if we have duplicate bottom borders (the artifact)
if (
currentLine.includes("╰") &&
currentLine.includes("╯") &&
nextLine.includes("╰") &&
nextLine.includes("╯")
) {
foundDuplicateBorder = true;
}
}
// The test should FAIL if we find duplicate borders (indicating the bug exists)
assert.strictEqual(foundDuplicateBorder, false, "Found duplicate bottom borders - rendering artifact detected!");
// Also check that there's only one bottom border total
const bottomBorderCount = finalViewport.filter((line) => line.includes("╰")).length;
assert.strictEqual(bottomBorderCount, 1, `Expected 1 bottom border, found ${bottomBorderCount}`);
// Verify the editor is back in its original position
const finalEditorStartLine = finalViewport.findIndex((line) => line.includes("╭"));
const initialEditorStartLine = initialViewport.findIndex((line) => line.includes("╭"));
assert.strictEqual(finalEditorStartLine, initialEditorStartLine);
ui.stop();
});
test("handles rapid addition and removal of components", async () => {
const term = new VirtualTerminal(80, 20);
const ui = new TUI(term);
const header = new TextComponent("Header");
const editor = new TextEditor({ multiline: false });
ui.addChild(header);
ui.addChild(editor);
// Initial render
ui.start();
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Rapidly add and remove a status message
const status = new TextComponent("Temporary Status");
// Add status
ui.children.splice(1, 0, status);
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Remove status immediately
ui.children.splice(1, 1);
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Final output check
const finalViewport = term.getViewport();
// Should only have one set of borders for the editor
const topBorderCount = finalViewport.filter((line) => line.includes("╭") && line.includes("╮")).length;
const bottomBorderCount = finalViewport.filter((line) => line.includes("╰") && line.includes("╯")).length;
assert.strictEqual(topBorderCount, 1);
assert.strictEqual(bottomBorderCount, 1);
// Check no duplicate lines
for (let i = 0; i < finalViewport.length - 1; i++) {
const currentLine = finalViewport[i];
const nextLine = finalViewport[i + 1];
// If current line is a bottom border, next line should not be a bottom border
if (currentLine.includes("╰") && currentLine.includes("╯")) {
assert.strictEqual(nextLine.includes("╰") && nextLine.includes("╯"), false);
}
}
ui.stop();
});
});

View file

@ -1,78 +0,0 @@
#!/usr/bin/env npx tsx
import { Container, MarkdownComponent, TextComponent, TextEditor, TUI } from "../src/index.js";
/**
* Multi-Component Layout Demo
*
* Demonstrates:
* - Complex layout with multiple containers
* - Header, sidebar, main content, and footer areas
* - Mixing static and dynamic components
* - Debug logging configuration
*/
const ui = new TUI();
// Create layout containers
const header = new TextComponent("📝 Advanced TUI Demo", { bottom: 1 });
const mainContent = new Container();
const sidebar = new Container();
const footer = new TextComponent("Press Ctrl+C to exit", { top: 1 });
// Sidebar content
sidebar.addChild(new TextComponent("📁 Files:", { bottom: 1 }));
sidebar.addChild(new TextComponent("- config.json"));
sidebar.addChild(new TextComponent("- README.md"));
sidebar.addChild(new TextComponent("- package.json"));
// Main content area
const chatArea = new Container();
const inputArea = new TextEditor();
// Add welcome message
chatArea.addChild(
new MarkdownComponent(`
# Welcome to the TUI Demo
This demonstrates multiple components working together:
- **Header**: Static title with padding
- **Sidebar**: File list (simulated)
- **Chat Area**: Scrollable message history
- **Input**: Interactive text editor
- **Footer**: Status information
Try typing a message and pressing Enter!
`),
);
inputArea.onSubmit = (text) => {
if (text.trim()) {
const message = new MarkdownComponent(`
**${new Date().toLocaleTimeString()}:** ${text}
`);
chatArea.addChild(message);
ui.requestRender();
}
};
// Build layout
mainContent.addChild(chatArea);
mainContent.addChild(inputArea);
ui.addChild(header);
ui.addChild(mainContent);
ui.addChild(footer);
ui.setFocus(inputArea);
// Handle Ctrl+C to exit
ui.onGlobalKeyPress = (data: string) => {
if (data === "\x03") {
ui.stop();
console.log("\nMulti-layout demo exited");
process.exit(0);
}
return true;
};
ui.start();

View file

@ -1,192 +0,0 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import { Container, LoadingAnimation, MarkdownComponent, TextComponent, TextEditor, TUI } from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("Multi-Message Garbled Output Reproduction", () => {
test("handles rapid message additions with large content without garbling", async () => {
const terminal = new VirtualTerminal(100, 30);
const ui = new TUI(terminal);
ui.start();
// Simulate the chat demo structure
const chatContainer = new Container();
const statusContainer = new Container();
const editor = new TextEditor();
ui.addChild(chatContainer);
ui.addChild(statusContainer);
ui.addChild(editor);
ui.setFocus(editor);
// Initial render
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 1: Simulate user message
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("read all README.md files except in node_modules"));
// Step 2: Start loading animation (assistant thinking)
const loadingAnim = new LoadingAnimation(ui, "Thinking");
statusContainer.addChild(loadingAnim);
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 3: Simulate rapid tool calls with large outputs
chatContainer.addChild(new TextComponent("[assistant]"));
// Simulate glob tool
chatContainer.addChild(new TextComponent('[tool] glob({"pattern":"**/README.md"})'));
const globResult = `README.md
node_modules/@biomejs/biome/README.md
node_modules/@esbuild/darwin-arm64/README.md
node_modules/@types/node/README.md
node_modules/@xterm/headless/README.md
node_modules/@xterm/xterm/README.md
node_modules/chalk/readme.md
node_modules/esbuild/README.md
node_modules/fsevents/README.md
node_modules/get-tsconfig/README.md
... (59 more lines)`;
chatContainer.addChild(new TextComponent(globResult));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Simulate multiple read tool calls with long content
const readmeContent = `# Pi Monorepo
A collection of tools for managing LLM deployments and building AI agents.
## Packages
- **[@mariozechner/pi-tui](packages/tui)** - Terminal UI library with differential rendering
- **[@mariozechner/pi-agent](packages/agent)** - General-purpose agent with tool calling and session persistence
- **[@mariozechner/pi](packages/pods)** - CLI for managing vLLM deployments on GPU pods
... (76 more lines)`;
// First read
chatContainer.addChild(new TextComponent('[tool] read({"path": "README.md"})'));
chatContainer.addChild(new MarkdownComponent(readmeContent));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Second read with even more content
const tuiReadmeContent = `# @mariozechner/pi-tui
Terminal UI framework with surgical differential rendering for building flicker-free interactive CLI applications.
## Features
- **Surgical Differential Rendering**: Three-strategy system that minimizes redraws to 1-2 lines for typical updates
- **Scrollback Buffer Preservation**: Correctly maintains terminal history when content exceeds viewport
- **Zero Flicker**: Components like text editors remain perfectly still while other parts update
- **Interactive Components**: Text editor with autocomplete, selection lists, markdown rendering
... (570 more lines)`;
chatContainer.addChild(new TextComponent('[tool] read({"path": "packages/tui/README.md"})'));
chatContainer.addChild(new MarkdownComponent(tuiReadmeContent));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 4: Stop loading animation and add assistant response
loadingAnim.stop();
statusContainer.clear();
const assistantResponse = `I've read the README files from your monorepo. Here's a summary:
The Pi Monorepo contains three main packages:
1. **pi-tui** - A terminal UI framework with advanced differential rendering
2. **pi-agent** - An AI agent with tool calling capabilities
3. **pi** - A CLI for managing GPU pods with vLLM
The TUI library features surgical differential rendering that minimizes screen updates.`;
chatContainer.addChild(new MarkdownComponent(assistantResponse));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 5: CRITICAL - Send a new message while previous content is displayed
chatContainer.addChild(new TextComponent("[user]"));
chatContainer.addChild(new TextComponent("What is the main purpose of the TUI library?"));
// Start new loading animation
const loadingAnim2 = new LoadingAnimation(ui, "Thinking");
statusContainer.addChild(loadingAnim2);
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Add assistant response
loadingAnim2.stop();
statusContainer.clear();
chatContainer.addChild(new TextComponent("[assistant]"));
const secondResponse = `The main purpose of the TUI library is to provide a **flicker-free terminal UI framework** with surgical differential rendering.
Key aspects:
- Minimizes screen redraws to only 1-2 lines for typical updates
- Preserves terminal scrollback buffer
- Enables building interactive CLI applications without visual artifacts`;
chatContainer.addChild(new MarkdownComponent(secondResponse));
ui.requestRender();
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Debug: Show the garbled output after the problematic step
console.log("\n=== After second read (where garbling occurs) ===");
const debugOutput = terminal.getScrollBuffer();
debugOutput.forEach((line, i) => {
if (line.trim()) console.log(`${i}: "${line}"`);
});
// Step 6: Check final output
const finalOutput = terminal.getScrollBuffer();
// Check that first user message is NOT garbled
const userLine1 = finalOutput.find((line) => line.includes("read all README.md files"));
assert.strictEqual(
userLine1,
"read all README.md files except in node_modules",
`First user message is garbled: "${userLine1}"`,
);
// Check that second user message is clean
const userLine2 = finalOutput.find((line) => line.includes("What is the main purpose"));
assert.strictEqual(
userLine2,
"What is the main purpose of the TUI library?",
`Second user message is garbled: "${userLine2}"`,
);
// Check for common garbling patterns
const garbledPatterns = [
"README.mdategy",
"README.mdectly",
"modulesl rendering",
"[assistant]ns.",
"node_modules/@esbuild/darwin-arm64/README.mdategy",
];
for (const pattern of garbledPatterns) {
const hasGarbled = finalOutput.some((line) => line.includes(pattern));
assert.ok(!hasGarbled, `Found garbled pattern "${pattern}" in output`);
}
ui.stop();
});
});

View file

@ -1,417 +0,0 @@
import assert from "node:assert";
import { describe, test } from "node:test";
import {
Container,
MarkdownComponent,
SelectList,
TextComponent,
TextEditor,
TUI,
WhitespaceComponent,
} from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.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

@ -1,164 +0,0 @@
import assert from "node:assert";
import { describe, test } from "node:test";
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();
});
});