Massive refactor of API

- Switch to function based API
- Anthropic SDK style async generator
- Fully typed with escape hatches for custom models
This commit is contained in:
Mario Zechner 2025-09-02 23:59:36 +02:00
parent 004de3c9d0
commit 66cefb236e
29 changed files with 5835 additions and 6225 deletions

View file

@ -1,15 +1,6 @@
#!/usr/bin/env npx tsx
import {
Container,
LoadingAnimation,
TextComponent,
TextEditor,
TUI,
WhitespaceComponent,
} from "../src/index.js";
import chalk from "chalk";
import { Container, LoadingAnimation, TextComponent, TextEditor, TUI, WhitespaceComponent } from "../src/index.js";
/**
* Test the new smart double-buffered TUI implementation
@ -24,7 +15,7 @@ async function main() {
// Monkey-patch requestRender to measure performance
const originalRequestRender = ui.requestRender.bind(ui);
ui.requestRender = function() {
ui.requestRender = () => {
const startTime = process.hrtime.bigint();
originalRequestRender();
process.nextTick(() => {
@ -38,10 +29,12 @@ async function main() {
// 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 }
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);
@ -57,7 +50,9 @@ async function main() {
// 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.");
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
@ -71,15 +66,20 @@ async function main() {
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 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)}`)
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);
@ -96,7 +96,11 @@ async function main() {
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(
`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);
@ -112,4 +116,4 @@ async function main() {
main().catch((error) => {
console.error("Error:", error);
process.exit(1);
});
});

View file

@ -1,9 +1,16 @@
#!/usr/bin/env npx tsx
import { TUI, Container, TextEditor, TextComponent, MarkdownComponent, CombinedAutocompleteProvider } from "../src/index.js";
import {
CombinedAutocompleteProvider,
Container,
MarkdownComponent,
TextComponent,
TextEditor,
TUI,
} from "../src/index.js";
/**
* Chat Application with Autocomplete
*
*
* Demonstrates:
* - Slash command system with autocomplete
* - Dynamic message history
@ -16,7 +23,7 @@ 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 }
{ bottom: 1 },
);
const chatHistory = new Container();
@ -82,7 +89,8 @@ ui.onGlobalKeyPress = (data: string) => {
};
// Add initial welcome message to chat history
chatHistory.addChild(new MarkdownComponent(`
chatHistory.addChild(
new MarkdownComponent(`
## Welcome to the Chat Demo!
**Available slash commands:**
@ -96,10 +104,11 @@ chatHistory.addChild(new MarkdownComponent(`
- 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();
ui.start();

View file

@ -1,7 +1,7 @@
import { test, describe } from "node:test";
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";
import { TUI, Container, TextComponent, TextEditor } from "../src/index.js";
describe("Differential Rendering - Dynamic Content", () => {
test("handles static text, dynamic container, and text editor correctly", async () => {
@ -23,7 +23,7 @@ describe("Differential Rendering - Dynamic Content", () => {
ui.setFocus(editor);
// Wait for next tick to complete and flush virtual terminal
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 4: Check initial output in scrollbuffer
@ -33,14 +33,16 @@ describe("Differential Rendering - Dynamic Content", () => {
console.log("Initial render:");
console.log("Viewport lines:", viewport.length);
console.log("ScrollBuffer lines:", scrollBuffer.length);
// Count non-empty lines in scrollbuffer
let nonEmptyInBuffer = scrollBuffer.filter(line => line.trim() !== "").length;
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`);
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...");
@ -52,7 +54,7 @@ describe("Differential Rendering - Dynamic Content", () => {
ui.requestRender();
// Wait for next tick to complete and flush
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 6: Check output after adding 100 components
@ -62,10 +64,10 @@ describe("Differential Rendering - Dynamic Content", () => {
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;
let allItemNumbers = new Set<number>();
const allItemNumbers = new Set<number>();
for (const line of scrollBuffer) {
const match = line.match(/Dynamic Item (\d+)/);
if (match) {
@ -73,31 +75,39 @@ describe("Differential Rendering - Dynamic Content", () => {
allItemNumbers.add(parseInt(match[1]));
}
}
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}`);
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");
// 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();
});
@ -124,7 +134,7 @@ describe("Differential Rendering - Dynamic Content", () => {
contentContainer.addChild(new TextComponent("Content Line 2"));
// Initial render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
let viewport = terminal.getViewport();
@ -142,7 +152,7 @@ describe("Differential Rendering - Dynamic Content", () => {
statusContainer.addChild(new TextComponent("Status: Processing..."));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
@ -162,7 +172,7 @@ describe("Differential Rendering - Dynamic Content", () => {
}
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
@ -180,7 +190,7 @@ describe("Differential Rendering - Dynamic Content", () => {
contentLine10.setText("Content Line 10 - MODIFIED");
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
viewport = terminal.getViewport();
@ -190,4 +200,4 @@ describe("Differential Rendering - Dynamic Content", () => {
ui.stop();
});
});
});

View file

@ -1,6 +1,6 @@
import { TUI, SelectList } from "../src/index.js";
import { readdirSync, statSync } from "fs";
import { join } from "path";
import { SelectList, TUI } from "../src/index.js";
const ui = new TUI();
ui.start();
@ -52,4 +52,4 @@ function showDirectory(path: string) {
ui.setFocus(fileList);
}
showDirectory(currentPath);
showDirectory(currentPath);

View file

@ -1,6 +1,6 @@
import { describe, test } from "node:test";
import assert from "node:assert";
import { TextEditor, TextComponent, Container, TUI } from "../src/index.js";
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", () => {
@ -27,7 +27,7 @@ describe("Layout shift artifacts", () => {
// Initial render
ui.start();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture initial state
@ -40,7 +40,7 @@ describe("Layout shift artifacts", () => {
ui.requestRender();
// Wait for render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture state with status message
@ -51,7 +51,7 @@ describe("Layout shift artifacts", () => {
ui.requestRender();
// Wait for render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Capture final state
@ -64,8 +64,12 @@ describe("Layout shift artifacts", () => {
const nextLine = finalViewport[i + 1];
// Check if we have duplicate bottom borders (the artifact)
if (currentLine.includes("╰") && currentLine.includes("╯") &&
nextLine.includes("╰") && nextLine.includes("╯")) {
if (
currentLine.includes("╰") &&
currentLine.includes("╯") &&
nextLine.includes("╰") &&
nextLine.includes("╯")
) {
foundDuplicateBorder = true;
}
}
@ -74,18 +78,12 @@ describe("Layout shift artifacts", () => {
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;
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("╭")
);
const finalEditorStartLine = finalViewport.findIndex((line) => line.includes("╭"));
const initialEditorStartLine = initialViewport.findIndex((line) => line.includes("╭"));
assert.strictEqual(finalEditorStartLine, initialEditorStartLine);
ui.stop();
@ -103,7 +101,7 @@ describe("Layout shift artifacts", () => {
// Initial render
ui.start();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await term.flush();
// Rapidly add and remove a status message
@ -112,25 +110,21 @@ describe("Layout shift artifacts", () => {
// Add status
ui.children.splice(1, 0, status);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
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 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;
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);
@ -148,4 +142,4 @@ describe("Layout shift artifacts", () => {
ui.stop();
});
});
});

View file

@ -1,5 +1,5 @@
#!/usr/bin/env npx tsx
import { TUI, Container, TextComponent, TextEditor, MarkdownComponent } from "../src/index.js";
import { Container, MarkdownComponent, TextComponent, TextEditor, TUI } from "../src/index.js";
/**
* Multi-Component Layout Demo
@ -75,4 +75,4 @@ ui.onGlobalKeyPress = (data: string) => {
return true;
};
ui.start();
ui.start();

View file

@ -1,7 +1,7 @@
import { test, describe } from "node:test";
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";
import { TUI, Container, TextComponent, MarkdownComponent, TextEditor, LoadingAnimation } from "../src/index.js";
describe("Multi-Message Garbled Output Reproduction", () => {
test("handles rapid message additions with large content without garbling", async () => {
@ -20,7 +20,7 @@ describe("Multi-Message Garbled Output Reproduction", () => {
ui.setFocus(editor);
// Initial render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 1: Simulate user message
@ -32,7 +32,7 @@ describe("Multi-Message Garbled Output Reproduction", () => {
statusContainer.addChild(loadingAnim);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 3: Simulate rapid tool calls with large outputs
@ -54,7 +54,7 @@ node_modules/get-tsconfig/README.md
chatContainer.addChild(new TextComponent(globResult));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Simulate multiple read tool calls with long content
@ -74,7 +74,7 @@ A collection of tools for managing LLM deployments and building AI agents.
chatContainer.addChild(new MarkdownComponent(readmeContent));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Second read with even more content
@ -94,7 +94,7 @@ Terminal UI framework with surgical differential rendering for building flicker-
chatContainer.addChild(new MarkdownComponent(tuiReadmeContent));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 4: Stop loading animation and add assistant response
@ -114,7 +114,7 @@ The TUI library features surgical differential rendering that minimizes screen u
chatContainer.addChild(new MarkdownComponent(assistantResponse));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Step 5: CRITICAL - Send a new message while previous content is displayed
@ -126,7 +126,7 @@ The TUI library features surgical differential rendering that minimizes screen u
statusContainer.addChild(loadingAnim2);
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Add assistant response
@ -144,7 +144,7 @@ Key aspects:
chatContainer.addChild(new MarkdownComponent(secondResponse));
ui.requestRender();
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Debug: Show the garbled output after the problematic step
@ -153,19 +153,25 @@ Key aspects:
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}"`);
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}"`);
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 = [
@ -173,14 +179,14 @@ Key aspects:
"README.mdectly",
"modulesl rendering",
"[assistant]ns.",
"node_modules/@esbuild/darwin-arm64/README.mdategy"
"node_modules/@esbuild/darwin-arm64/README.mdategy",
];
for (const pattern of garbledPatterns) {
const hasGarbled = finalOutput.some(line => line.includes(pattern));
const hasGarbled = finalOutput.some((line) => line.includes(pattern));
assert.ok(!hasGarbled, `Found garbled pattern "${pattern}" in output`);
}
ui.stop();
});
});
});

View file

@ -1,18 +1,17 @@
import { test, describe } from "node:test";
import assert from "node:assert";
import { VirtualTerminal } from "./virtual-terminal.js";
import { describe, test } from "node:test";
import {
TUI,
Container,
TextComponent,
TextEditor,
WhitespaceComponent,
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);
@ -22,7 +21,7 @@ describe("TUI Rendering", () => {
ui.addChild(text);
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
// Wait for writes to complete and get the rendered output
const output = await terminal.flushAndGetViewport();
@ -48,7 +47,7 @@ describe("TUI Rendering", () => {
ui.addChild(new TextComponent("Line 3"));
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
@ -68,7 +67,7 @@ describe("TUI Rendering", () => {
ui.addChild(new TextComponent("Bottom text"));
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Top text");
@ -96,7 +95,7 @@ describe("TUI Rendering", () => {
ui.addChild(new TextComponent("After container"));
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Before container");
@ -117,11 +116,11 @@ describe("TUI Rendering", () => {
ui.setFocus(editor);
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
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("╮"));
@ -142,7 +141,7 @@ describe("TUI Rendering", () => {
ui.addChild(dynamicText);
// Wait for initial render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Save initial state
@ -153,8 +152,8 @@ describe("TUI Rendering", () => {
ui.requestRender();
// Wait for render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
// Flush terminal buffer
await terminal.flush();
@ -180,7 +179,7 @@ describe("TUI Rendering", () => {
ui.addChild(text3);
// Wait for initial render
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
let output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
@ -191,7 +190,7 @@ describe("TUI Rendering", () => {
ui.removeChild(text2);
ui.requestRender();
await new Promise(resolve => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
@ -212,7 +211,7 @@ describe("TUI Rendering", () => {
}
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
@ -241,7 +240,7 @@ describe("TUI Rendering", () => {
ui.addChild(new TextComponent("After"));
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Before");
@ -262,7 +261,7 @@ describe("TUI Rendering", () => {
ui.addChild(markdown);
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
// Should have formatted markdown
@ -289,7 +288,7 @@ describe("TUI Rendering", () => {
ui.setFocus(selectList);
// Wait for next tick for render to complete
await new Promise(resolve => process.nextTick(resolve));
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
// First option should be selected (has → indicator)
@ -303,28 +302,28 @@ describe("TUI Rendering", () => {
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) => {
@ -332,87 +331,87 @@ describe("TUI Rendering", () => {
};
ui.addChild(editor);
ui.setFocus(editor);
// Wait for initial render
await new Promise(resolve => process.nextTick(resolve));
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 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 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,5 +1,5 @@
import { test, describe } from "node:test";
import assert from "node:assert";
import { describe, test } from "node:test";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("VirtualTerminal", () => {
@ -86,13 +86,13 @@ describe("VirtualTerminal", () => {
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
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");
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'");
});
@ -129,9 +129,12 @@ describe("VirtualTerminal", () => {
const terminal = new VirtualTerminal(80, 24);
let received = "";
terminal.start((data) => {
received = data;
}, () => {});
terminal.start(
(data) => {
received = data;
},
() => {},
);
terminal.sendInput("a");
assert.strictEqual(received, "a");
@ -146,13 +149,16 @@ describe("VirtualTerminal", () => {
const terminal = new VirtualTerminal(80, 24);
let resized = false;
terminal.start(() => {}, () => {
resized = true;
});
terminal.start(
() => {},
() => {
resized = true;
},
);
terminal.resize(100, 30);
assert.strictEqual(resized, true);
terminal.stop();
});
});
});

View file

@ -1,6 +1,6 @@
import xterm from '@xterm/headless';
import type { Terminal as XtermTerminalType } from '@xterm/headless';
import { Terminal } from '../src/terminal.js';
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
import type { Terminal } from "../src/terminal.js";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
@ -81,7 +81,7 @@ export class VirtualTerminal implements Terminal {
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>((resolve) => {
this.xterm.write('', () => resolve());
this.xterm.write("", () => resolve());
});
}
@ -107,7 +107,7 @@ export class VirtualTerminal implements Terminal {
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push('');
lines.push("");
}
}
@ -127,7 +127,7 @@ export class VirtualTerminal implements Terminal {
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push('');
lines.push("");
}
}
@ -155,7 +155,7 @@ export class VirtualTerminal implements Terminal {
const buffer = this.xterm.buffer.active;
return {
x: buffer.cursorX,
y: buffer.cursorY
y: buffer.cursorY,
};
}
}
}