mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 22:01:41 +00:00
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:
parent
1caa3cc1a7
commit
985f955ea0
40 changed files with 998 additions and 4516 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue