move pi-mono into companion-cloud as apps/companion-os

- Copy all pi-mono source into apps/companion-os/
- Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases
- Update deploy-staging.yml to build pi from source (bun compile) before Docker build
- Add apps/companion-os/** to path triggers
- No more cross-repo dispatch needed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harivansh Rathi 2026-03-07 09:22:50 -08:00
commit 0250f72976
579 changed files with 206942 additions and 0 deletions

View file

@ -0,0 +1,521 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, it, test } from "node:test";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
const resolveFdPath = (): string | null => {
const command = process.platform === "win32" ? "where" : "which";
const result = spawnSync(command, ["fd"], { encoding: "utf-8" });
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
return firstLine ? firstLine.trim() : null;
};
type FolderStructure = {
dirs?: string[];
files?: Record<string, string>;
};
const setupFolder = (
baseDir: string,
structure: FolderStructure = {},
): void => {
const dirs = structure.dirs ?? [];
const files = structure.files ?? {};
dirs.forEach((dir) => {
mkdirSync(join(baseDir, dir), { recursive: true });
});
Object.entries(files).forEach(([filePath, contents]) => {
const fullPath = join(baseDir, filePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, contents);
});
};
const fdPath = resolveFdPath();
const isFdInstalled = Boolean(fdPath);
const requireFdPath = (): string => {
if (!fdPath) {
throw new Error("fd is not available");
}
return fdPath;
};
describe("CombinedAutocompleteProvider", () => {
describe("extractPathPrefix", () => {
it("extracts / from 'hey /' when forced", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["hey /"];
const cursorLine = 0;
const cursorCol = 5; // After the "/"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
assert.notEqual(
result,
null,
"Should return suggestions for root directory",
);
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
it("extracts /A from '/A' when forced", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/A"];
const cursorLine = 0;
const cursorCol = 2; // After the "A"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
// This might return null if /A doesn't match anything, which is fine
// We're mainly testing that the prefix extraction works
if (result) {
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
}
});
it("does not trigger for slash commands", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/model"];
const cursorLine = 0;
const cursorCol = 6; // After "model"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
assert.strictEqual(result, null, "Should not trigger for slash commands");
});
it("triggers for absolute paths after slash command argument", () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/command /"];
const cursorLine = 0;
const cursorCol = 10; // After the second "/"
const result = provider.getForceFileSuggestions(
lines,
cursorLine,
cursorCol,
);
console.log("Result:", result);
assert.notEqual(
result,
null,
"Should trigger for absolute paths in command arguments",
);
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
});
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
let rootDir = "";
let baseDir = "";
let outsideDir = "";
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-"));
baseDir = join(rootDir, "cwd");
outsideDir = join(rootDir, "outside");
mkdirSync(baseDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
test("returns all files and folders for empty @ query", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
});
test("matches file with extension in query", () => {
setupFolder(baseDir, {
files: {
"file.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@file.txt";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@file.txt"));
});
test("filters are case insensitive", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@re";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md"]);
});
test("ranks directories before files", () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"src.txt": "text",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@src";
const result = provider.getSuggestions([line], 0, line.length);
const firstValue = result?.items[0]?.value;
const hasSrcFile = result?.items?.some(
(item) => item.value === "@src.txt",
);
assert.strictEqual(firstValue, "@src/");
assert.ok(hasSrcFile);
});
test("returns nested file paths", () => {
setupFolder(baseDir, {
files: {
"src/index.ts": "export {};\n",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@index";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/index.ts"));
});
test("matches deeply nested paths", () => {
setupFolder(baseDir, {
files: {
"packages/tui/src/autocomplete.ts": "export {};",
"packages/ai/src/autocomplete.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@tui/src/auto";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
});
test("matches directory in middle of path with --full-path", () => {
setupFolder(baseDir, {
files: {
"src/components/Button.tsx": "export {};",
"src/utils/helpers.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@components/";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/components/Button.tsx"));
assert.ok(!values?.includes("@src/utils/helpers.ts"));
});
test("scopes fuzzy search to relative directories and searches recursively", () => {
setupFolder(outsideDir, {
files: {
"nested/alpha.ts": "export {};",
"nested/deeper/also-alpha.ts": "export {};",
"nested/deeper/zzz.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@../outside/a";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts"));
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
});
test("quotes paths with spaces for @ suggestions", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@my";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/"'));
});
test("includes hidden paths but excludes .git", () => {
setupFolder(baseDir, {
dirs: [".pi", ".github", ".git"],
files: {
".pi/config.json": "{}",
".github/workflows/ci.yml": "name: ci",
".git/config": "[core]",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = "@";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@.pi/"));
assert.ok(values.includes("@.github/"));
assert.ok(
!values.some(
(value) => value === "@.git" || value.startsWith("@.git/"),
),
);
});
test("continues autocomplete inside quoted @ paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = '@"my folder/"';
const result = provider.getSuggestions([line], 0, line.length - 1);
assert.notEqual(
result,
null,
"Should return suggestions for quoted folder path",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/test.txt"'));
assert.ok(values?.includes('@"my folder/other.txt"'));
});
test("applies quoted @ completion without duplicating closing quote", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider(
[],
baseDir,
requireFdPath(),
);
const line = '@"my folder/te"';
const cursorCol = line.length - 1;
const result = provider.getSuggestions([line], 0, cursorCol);
assert.notEqual(
result,
null,
"Should return suggestions for quoted @ path",
);
const item = result?.items.find(
(entry) => entry.value === '@"my folder/test.txt"',
);
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion(
[line],
0,
cursorCol,
item!,
result!.prefix,
);
assert.strictEqual(applied.lines[0], '@"my folder/test.txt" ');
});
});
describe("quoted path completion", () => {
let baseDir = "";
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
test("quotes paths with spaces for direct completion", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "my";
const result = provider.getForceFileSuggestions([line], 0, line.length);
assert.notEqual(
result,
null,
"Should return suggestions for path completion",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/"'));
});
test("continues completion inside quoted paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/"';
const result = provider.getForceFileSuggestions(
[line],
0,
line.length - 1,
);
assert.notEqual(
result,
null,
"Should return suggestions for quoted folder path",
);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/test.txt"'));
assert.ok(values?.includes('"my folder/other.txt"'));
});
test("applies quoted completion without duplicating closing quote", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/te"';
const cursorCol = line.length - 1;
const result = provider.getForceFileSuggestions([line], 0, cursorCol);
assert.notEqual(
result,
null,
"Should return suggestions for quoted path",
);
const item = result?.items.find(
(entry) => entry.value === '"my folder/test.txt"',
);
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion(
[line],
0,
cursorCol,
item!,
result!.prefix,
);
assert.strictEqual(applied.lines[0], '"my folder/test.txt"');
});
});
});

View file

@ -0,0 +1,283 @@
/**
* Bug regression test for isImageLine() crash scenario
*
* Bug: When isImageLine() used startsWith() and terminal doesn't support images,
* it would return false for lines containing image escape sequences, causing TUI to
* crash with "Rendered line exceeds terminal width" error.
*
* Fix: Changed to use includes() to detect escape sequences anywhere in the line.
*
* This test demonstrates:
* 1. The bug scenario with the old implementation
* 2. That the fix works correctly
*/
import assert from "node:assert";
import { describe, it } from "node:test";
describe("Bug regression: isImageLine() crash with image escape sequences", () => {
describe("Bug scenario: Terminal without image support", () => {
it("old implementation would return false, causing crash", () => {
/**
* OLD IMPLEMENTATION (buggy):
* ```typescript
* export function isImageLine(line: string): boolean {
* const prefix = getImageEscapePrefix();
* return prefix !== null && line.startsWith(prefix);
* }
* ```
*
* When terminal doesn't support images:
* - getImageEscapePrefix() returns null
* - isImageLine() returns false even for lines containing image sequences
* - TUI performs width check on line containing 300KB+ of base64 data
* - Crash: "Rendered line exceeds terminal width (304401 > 115)"
*/
// Simulate old implementation behavior
const oldIsImageLine = (
line: string,
imageEscapePrefix: string | null,
): boolean => {
return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix);
};
// When terminal doesn't support images, prefix is null
const terminalWithoutImageSupport = null;
// Line containing image escape sequence with text before it (common bug scenario)
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// Old implementation would return false (BUG!)
const oldResult = oldIsImageLine(
lineWithImageSequence,
terminalWithoutImageSupport,
);
assert.strictEqual(
oldResult,
false,
"Bug: old implementation returns false for line containing image sequence when terminal has no image support",
);
});
it("new implementation returns true correctly", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line containing image escape sequence with text before it
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// New implementation should return true (FIX!)
const newResult = isImageLine(lineWithImageSequence);
assert.strictEqual(
newResult,
true,
"Fix: new implementation returns true for line containing image sequence",
);
});
it("new implementation detects Kitty sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b_Ga=T,f=100,data...\x1b\\",
"Prefix \x1b_Ga=T,data...\x1b\\",
"Suffix text \x1b_Ga=T,data...\x1b\\ suffix",
"Middle \x1b_Ga=T,data...\x1b\\ more text",
// Very long line (simulating 300KB+ crash scenario)
`Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect Kitty sequence in: ${line.slice(0, 50)}...`,
);
}
});
it("new implementation detects iTerm2 sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b]1337;File=size=100,100:base64...\x07",
"Prefix \x1b]1337;File=inline=1:data==\x07",
"Suffix text \x1b]1337;File=inline=1:data==\x07 suffix",
"Middle \x1b]1337;File=inline=1:data==\x07 more text",
// Very long line (simulating 304KB crash scenario)
`Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`,
);
}
});
});
describe("Integration: Tool execution scenario", () => {
/**
* This simulates what happens when the `read` tool reads an image file.
* The tool result contains both text and image content:
*
* ```typescript
* {
* content: [
* { type: "text", text: "Read image file [image/jpeg]\n800x600" },
* { type: "image", data: "base64...", mimeType: "image/jpeg" }
* ]
* }
* ```
*
* When this is rendered, the image component creates escape sequences.
* If isImageLine() doesn't detect them, TUI crashes.
*/
it("detects image sequences in read tool output", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Simulate output when read tool processes an image
// The line might have text from the read result plus the image escape sequence
const toolOutputLine =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07";
assert.strictEqual(
isImageLine(toolOutputLine),
true,
"Should detect image sequence in tool output line",
);
});
it("detects Kitty sequences from Image component", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Kitty image component creates multi-line output with escape sequences
const kittyLine =
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(
isImageLine(kittyLine),
true,
"Should detect Kitty image component output",
);
});
it("handles ANSI codes before image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line might have styling (error, warning, etc.) before image data
const lines = [
"\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07",
"\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\",
"\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m",
];
for (const line of lines) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`,
);
}
});
});
describe("Crash scenario simulation", () => {
it("does NOT crash on very long lines with image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Simulate the exact crash scenario:
* - Line is 304,401 characters (the crash log showed 58649 > 115)
* - Contains image escape sequence somewhere in the middle
* - Old implementation would return false, causing TUI to do width check
* - New implementation returns true, skipping width check (preventing crash)
*/
const base64Char = "A".repeat(100);
const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a line that would cause the crash
const crashLine =
"Output: " +
iterm2Sequence +
base64Char.repeat(3040) + // ~304,000 chars
" end of output";
// Verify line is very long
assert(crashLine.length > 300000, "Test line should be > 300KB");
// New implementation should detect it (prevents crash)
const detected = isImageLine(crashLine);
assert.strictEqual(
detected,
true,
"Should detect image sequence in very long line, preventing TUI crash",
);
});
it("handles lines exactly matching crash log dimensions", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Crash log showed: line 58649 chars wide, terminal width 115
* Let's create a line with similar characteristics
*/
const targetWidth = 58649;
const prefix = "Text";
const sequence = "\x1b_Ga=T,f=100";
const suffix = "End";
const padding = "A".repeat(
targetWidth - prefix.length - sequence.length - suffix.length,
);
const line = `${prefix}${sequence}${padding}${suffix}`;
assert.strictEqual(line.length, 58649);
assert.strictEqual(
isImageLine(line),
true,
"Should detect image sequence in 58649-char line",
);
});
});
describe("Negative cases: Don't false positive", () => {
it("does not detect images in regular long text", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Very long line WITHOUT image sequences
const longText = "A".repeat(100000);
assert.strictEqual(
isImageLine(longText),
false,
"Should not detect images in plain long text",
);
});
it("does not detect images in lines with file paths", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const filePaths = [
"/path/to/1337/image.jpg",
"/usr/local/bin/File_converter",
"~/Documents/1337File_backup.png",
"./_G_test_file.txt",
];
for (const path of filePaths) {
assert.strictEqual(
isImageLine(path),
false,
`Should not falsely detect image sequence in path: ${path}`,
);
}
});
});
});

View file

@ -0,0 +1,137 @@
/**
* Simple chat interface demo using tui.ts
*/
import chalk from "chalk";
import { CombinedAutocompleteProvider } from "../src/autocomplete.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 { TUI } from "../src/tui.js";
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Create chat container with some initial messages
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(tui, defaultEditorTheme);
// Set up autocomplete provider with slash commands and file completion
const autocompleteProvider = new CombinedAutocompleteProvider(
[
{ name: "delete", description: "Delete the last message" },
{ name: "clear", description: "Clear all messages" },
],
process.cwd(),
);
editor.setAutocompleteProvider(autocompleteProvider);
tui.addChild(editor);
// Focus the editor
tui.setFocus(editor);
// Track if we're waiting for bot response
let isResponding = false;
// Handle message submission
editor.onSubmit = (value: string) => {
// Prevent submission if already responding
if (isResponding) {
return;
}
const trimmed = value.trim();
// Handle slash commands
if (trimmed === "/delete") {
const children = tui.children;
// Remove component before editor (if there are any besides the initial text)
if (children.length > 3) {
// children[0] = "Welcome to Simple Chat!"
// children[1] = "Type your messages below..."
// children[2...n-1] = messages
// children[n] = editor
children.splice(children.length - 2, 1);
}
tui.requestRender();
return;
}
if (trimmed === "/clear") {
const children = tui.children;
// Remove all messages but keep the welcome text and editor
children.splice(2, children.length - 3);
tui.requestRender();
return;
}
if (trimmed) {
isResponding = true;
editor.disableSubmit = true;
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
const children = tui.children;
children.splice(children.length - 1, 0, userMessage);
const loader = new Loader(
tui,
(s) => chalk.cyan(s),
(s) => chalk.dim(s),
"Thinking...",
);
children.splice(children.length - 1, 0, loader);
tui.requestRender();
setTimeout(() => {
tui.removeChild(loader);
// Simulate a response
const responses = [
"That's interesting! Tell me more.",
"I see what you mean.",
"Fascinating perspective!",
"Could you elaborate on that?",
"That makes sense to me.",
"I hadn't thought of it that way.",
"Great point!",
"Thanks for sharing that.",
];
const randomResponse =
responses[Math.floor(Math.random() * responses.length)];
// Add assistant message with no background (transparent)
const botMessage = new Markdown(
randomResponse,
1,
1,
defaultMarkdownTheme,
);
children.splice(children.length - 1, 0, botMessage);
// Re-enable submit
isResponding = false;
editor.disableSubmit = false;
// Request render
tui.requestRender();
}, 1000);
}
};
// Start the TUI
tui.start();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,102 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
it("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
assert.strictEqual(result.matches, true);
assert.strictEqual(result.score, 0);
});
it("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
assert.strictEqual(result.matches, false);
});
it("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
assert.strictEqual(result.matches, true);
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
});
it("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
assert.strictEqual(matchInOrder.matches, true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
assert.strictEqual(matchOutOfOrder.matches, false);
});
it("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
assert.strictEqual(result.matches, true);
const result2 = fuzzyMatch("abc", "ABC");
assert.strictEqual(result2.matches, true);
});
it("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
assert.strictEqual(consecutive.matches, true);
assert.strictEqual(scattered.matches, true);
assert.ok(consecutive.score < scattered.score);
});
it("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
assert.strictEqual(atBoundary.matches, true);
assert.strictEqual(notAtBoundary.matches, true);
assert.ok(atBoundary.score < notAtBoundary.score);
});
it("matches swapped alpha numeric tokens", () => {
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
assert.strictEqual(result.matches, true);
});
});
describe("fuzzyFilter", () => {
it("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x: string) => x);
assert.deepStrictEqual(result, items);
});
it("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x: string) => x);
assert.ok(result.includes("banana"));
assert.ok(!result.includes("apple"));
assert.ok(!result.includes("cherry"));
});
it("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x: string) => x);
// "app" should be first (exact consecutive match at start)
assert.strictEqual(result[0], "app");
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(
items,
"foo",
(item: { name: string; id: number }) => item.name,
);
assert.strictEqual(result.length, 2);
assert.ok(result.map((r) => r.name).includes("foo"));
assert.ok(result.map((r) => r.name).includes("foobar"));
});
});

View file

@ -0,0 +1,62 @@
import { readFileSync } from "fs";
import { Image } from "../src/components/image.js";
import { Spacer } from "../src/components/spacer.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { getCapabilities, getImageDimensions } from "../src/terminal-image.js";
import { TUI } from "../src/tui.js";
const testImagePath = process.argv[2] || "/tmp/test-image.png";
console.log("Terminal capabilities:", getCapabilities());
console.log("Loading image from:", testImagePath);
let imageBuffer: Buffer;
try {
imageBuffer = readFileSync(testImagePath);
} catch (_e) {
console.error(`Failed to load image: ${testImagePath}`);
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
process.exit(1);
}
const base64Data = imageBuffer.toString("base64");
const dims = getImageDimensions(base64Data, "image/png");
console.log("Image dimensions:", dims);
console.log("");
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(new Text("Image Rendering Test", 1, 1));
tui.addChild(new Spacer(1));
if (dims) {
tui.addChild(
new Image(
base64Data,
"image/png",
{ fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` },
{ maxWidthCells: 60 },
dims,
),
);
} else {
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
}
tui.addChild(new Spacer(1));
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
const editor = {
handleInput(data: string) {
if (data.charCodeAt(0) === 3) {
tui.stop();
process.exit(0);
}
},
};
tui.setFocus(editor as any);
tui.start();

View file

@ -0,0 +1,530 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Input } from "../src/components/input.js";
describe("Input component", () => {
it("submits value including backslash on Enter", () => {
const input = new Input();
let submitted: string | undefined;
input.onSubmit = (value) => {
submitted = value;
};
// Type hello, then backslash, then Enter
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\\");
input.handleInput("\r");
// Input is single-line, no backslash+Enter workaround
assert.strictEqual(submitted, "hello\\");
});
it("inserts backslash as regular character", () => {
const input = new Input();
input.handleInput("\\");
input.handleInput("x");
assert.strictEqual(input.getValue(), "\\x");
});
describe("Kill ring", () => {
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
const input = new Input();
input.setValue("foo bar baz");
// Move cursor to end
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
// Move to beginning and yank
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "bazfoo bar ");
});
it("Ctrl+U saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
// Move cursor to after "hello "
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U - deletes "hello "
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+K saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+Y does nothing when kill ring is empty", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "test");
});
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
const input = new Input();
// Create kill ring with multiple entries
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "third"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
assert.strictEqual(input.getValue(), "third");
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("\x1by"); // Alt+Y - cycles to "first"
assert.strictEqual(input.getValue(), "first");
input.handleInput("\x1by"); // Alt+Y - cycles back to "third"
assert.strictEqual(input.getValue(), "third");
});
it("Alt+Y does nothing if not preceded by yank", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "test"
input.setValue("other");
input.handleInput("\x05"); // Ctrl+E
// Type something to break the yank chain
input.handleInput("x");
assert.strictEqual(input.getValue(), "otherx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "otherx");
});
it("Alt+Y does nothing if kill ring has one entry", () => {
const input = new Input();
input.setValue("only");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "only"
input.handleInput("\x19"); // Ctrl+Y - yanks "only"
assert.strictEqual(input.getValue(), "only");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "only");
});
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
const input = new Input();
input.setValue("one two three");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "three"
input.handleInput("\x17"); // Ctrl+W - deletes "two "
input.handleInput("\x17"); // Ctrl+W - deletes "one "
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "one two three");
});
it("non-delete actions break kill accumulation", () => {
const input = new Input();
input.setValue("foo bar baz");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("x"); // Typing breaks accumulation
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry)
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("\x19"); // Ctrl+Y - most recent is "x"
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x1by"); // Alt+Y - cycle to "baz"
assert.strictEqual(input.getValue(), "foo bar baz");
});
it("non-yank actions break Alt+Y chain", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("x"); // Breaks yank chain
assert.strictEqual(input.getValue(), "secondx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "secondx");
});
it("kill ring rotation persists after cycling", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "third"
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
// Break chain and start fresh
input.handleInput("x");
input.setValue("");
// New yank should get "second" (now at end after rotation)
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "second");
});
it("backward deletions prepend, forward deletions append during accumulation", () => {
const input = new Input();
input.setValue("prefix|suffix");
// Position cursor at "|"
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6
input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward)
assert.strictEqual(input.getValue(), "prefix");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "prefix|suffix");
});
it("Alt+D deletes word forward and saves to kill ring", () => {
const input = new Input();
input.setValue("hello world test");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world test");
input.handleInput("\x1bd"); // Alt+D - deletes " world"
assert.strictEqual(input.getValue(), " test");
// Yank should get accumulated text
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world test");
});
it("handles yank in middle of text", () => {
const input = new Input();
input.setValue("word");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "word"
input.setValue("hello world");
// Move to middle (after "hello ")
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello wordworld");
});
it("handles yank-pop in middle of text", () => {
const input = new Input();
// Create two kill ring entries
input.setValue("FIRST");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
input.setValue("SECOND");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
// Set up "hello world" and position cursor after "hello "
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND"
assert.strictEqual(input.getValue(), "hello SECONDworld");
input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST"
assert.strictEqual(input.getValue(), "hello FIRSTworld");
});
});
describe("Undo", () => {
it("does nothing when undo stack is empty", () => {
const input = new Input();
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("coalesces consecutive word characters into one undo unit", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
// Undo removes " world"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
// Undo removes "hello"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes spaces one at a time", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput(" ");
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
assert.strictEqual(input.getValue(), "hello");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
assert.strictEqual(input.getValue(), "");
});
it("undoes backspace", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x7f"); // Backspace
assert.strictEqual(input.getValue(), "hell");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes forward delete", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x01"); // Ctrl+A - go to start
input.handleInput("\x1b[C"); // Right arrow
input.handleInput("\x1b[3~"); // Delete key
assert.strictEqual(input.getValue(), "hllo");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes Ctrl+W (delete word backward)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
input.handleInput("\x17"); // Ctrl+W
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+K (delete to line end)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x0b"); // Ctrl+K
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+U (delete to line start)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes yank", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("\x17"); // Ctrl+W - delete "hello "
input.handleInput("\x19"); // Ctrl+Y - yank
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes paste atomically", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
// Simulate bracketed paste
input.handleInput("\x1b[200~beep boop\x1b[201~");
assert.strictEqual(input.getValue(), "hellobeep boop world");
// Single undo should restore entire pre-paste state
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Alt+D (delete word forward)", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("cursor movement starts new undo unit", () => {
const input = new Input();
input.handleInput("a");
input.handleInput("b");
input.handleInput("c");
input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing
input.handleInput("\x05"); // Ctrl+E
input.handleInput("d");
input.handleInput("e");
assert.strictEqual(input.getValue(), "abcde");
// Undo removes "de" (typed after movement)
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "abc");
// Undo removes "abc"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
});
});

113
packages/tui/test/key-tester.ts Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env node
import { matchesKey } from "../src/keys.js";
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
/**
* Simple key code logger component
*/
class KeyLogger implements Component {
private log: string[] = [];
private maxLines = 20;
private tui: TUI;
constructor(tui: TUI) {
this.tui = tui;
}
handleInput(data: string): void {
// Handle Ctrl+C (raw or Kitty protocol) for exit
if (matchesKey(data, "ctrl+c")) {
this.tui.stop();
console.log("\nExiting...");
process.exit(0);
}
// Convert to various representations
const hex = Buffer.from(data).toString("hex");
const charCodes = Array.from(data)
.map((c) => c.charCodeAt(0))
.join(", ");
const repr = data
.replace(/\x1b/g, "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\x7f/g, "\\x7f");
const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`;
this.log.push(logLine);
// Keep only last N lines
if (this.log.length > this.maxLines) {
this.log.shift();
}
// Request re-render to show the new log entry
this.tui.requestRender();
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// Title
lines.push("=".repeat(width));
lines.push(
"Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(
width,
),
);
lines.push("=".repeat(width));
lines.push("");
// Log entries
for (const entry of this.log) {
lines.push(entry.padEnd(width));
}
// Fill remaining space
const remaining = Math.max(0, 25 - lines.length);
for (let i = 0; i < remaining; i++) {
lines.push("".padEnd(width));
}
// Footer
lines.push("=".repeat(width));
lines.push("Test these:".padEnd(width));
lines.push(
" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(
width,
),
);
lines.push(" - Alt/Option + Enter".padEnd(width));
lines.push(" - Option/Alt + Backspace".padEnd(width));
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
lines.push(" - Regular Backspace".padEnd(width));
lines.push("=".repeat(width));
return lines;
}
}
// Set up TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const logger = new KeyLogger(tui);
tui.addChild(logger);
tui.setFocus(logger);
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
process.on("SIGINT", () => {
tui.stop();
console.log("\nExiting...");
process.exit(0);
});
// Start the TUI
tui.start();

View file

@ -0,0 +1,349 @@
/**
* Tests for keyboard input handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
describe("matchesKey", () => {
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
// Kitty protocol flag 4 (Report alternate keys) sends:
// CSI codepoint:shifted:base ; modifier:event u
// Where base is the key in standard PC-101 layout
it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99
// Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5)
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100
const cyrillicCtrlD = "\x1b[1074::100;5u";
assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122
const cyrillicCtrlZ = "\x1b[1103::122;5u";
assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+Shift+p with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112
// ctrl=4, shift=1, +1 = 6
const cyrillicCtrlShiftP = "\x1b[1079::112;6u";
assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true);
setKittyProtocolActive(false);
});
it("should still match direct codepoint when no base layout key", () => {
setKittyProtocolActive(true);
// Latin ctrl+c without base layout key (terminal doesn't support flag 4)
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
// Latin 'c' with shifted 'C' (67) and base 'c' (99)
const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2
assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true);
setKittyProtocolActive(false);
});
it("should handle event type in format", () => {
setKittyProtocolActive(true);
// Format with event type: CSI codepoint::base;modifier:event u
// Cyrillic ctrl+c release event (event type 3)
const releaseEvent = "\x1b[1089::99;5:3u";
assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should handle full format with shifted key, base key, and event type", () => {
setKittyProtocolActive(true);
// Full format: CSI codepoint:shifted:base;modifier:event u
// Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event
// Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99
// ctrl=4, shift=1, +1 = 6, repeat event = 2
const fullFormat = "\x1b[1089:1057:99;6:2u";
assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true);
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
setKittyProtocolActive(false);
});
it("should not match wrong key even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base 'c' should NOT match ctrl+d
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false);
setKittyProtocolActive(false);
});
it("should not match wrong modifiers even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с should NOT match ctrl+shift+c
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false);
setKittyProtocolActive(false);
});
});
describe("Legacy key matching", () => {
it("should match legacy Ctrl+c", () => {
setKittyProtocolActive(false);
// Ctrl+c sends ASCII 3 (ETX)
assert.strictEqual(matchesKey("\x03", "ctrl+c"), true);
});
it("should match legacy Ctrl+d", () => {
setKittyProtocolActive(false);
// Ctrl+d sends ASCII 4 (EOT)
assert.strictEqual(matchesKey("\x04", "ctrl+d"), true);
});
it("should match escape key", () => {
assert.strictEqual(matchesKey("\x1b", "escape"), true);
});
it("should match legacy linefeed as enter", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\n", "enter"), true);
assert.strictEqual(parseKey("\n"), "enter");
});
it("should treat linefeed as shift+enter when kitty active", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
assert.strictEqual(matchesKey("\n", "enter"), false);
assert.strictEqual(parseKey("\n"), "shift+enter");
setKittyProtocolActive(false);
});
it("should parse ctrl+space", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
assert.strictEqual(parseKey("\x00"), "ctrl+space");
});
it("should match legacy Ctrl+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true);
assert.strictEqual(parseKey("\x1c"), "ctrl+\\");
// Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true);
assert.strictEqual(parseKey("\x1d"), "ctrl+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true);
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
assert.strictEqual(parseKey("\x1f"), "ctrl+-");
});
it("should match legacy Ctrl+Alt+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC)
assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true);
assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+[");
// Ctrl+Alt+\ sends ESC followed by ASCII 28
assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true);
assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\");
// Ctrl+Alt+] sends ESC followed by ASCII 29
assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true);
assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true);
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true);
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
});
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
assert.strictEqual(parseKey("\x1b "), "alt+space");
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
assert.strictEqual(parseKey("\x1bB"), "alt+left");
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
assert.strictEqual(parseKey("\x1bF"), "alt+right");
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
assert.strictEqual(parseKey("\x1ba"), "alt+a");
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
assert.strictEqual(parseKey("\x1by"), "alt+y");
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
assert.strictEqual(parseKey("\x1bz"), "alt+z");
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
assert.strictEqual(parseKey("\x1b "), undefined);
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
assert.strictEqual(parseKey("\x1b\x03"), undefined);
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
assert.strictEqual(parseKey("\x1bB"), undefined);
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
assert.strictEqual(parseKey("\x1bF"), undefined);
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
assert.strictEqual(parseKey("\x1ba"), undefined);
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
assert.strictEqual(parseKey("\x1by"), undefined);
setKittyProtocolActive(false);
});
it("should match arrow keys", () => {
assert.strictEqual(matchesKey("\x1b[A", "up"), true);
assert.strictEqual(matchesKey("\x1b[B", "down"), true);
assert.strictEqual(matchesKey("\x1b[C", "right"), true);
assert.strictEqual(matchesKey("\x1b[D", "left"), true);
});
it("should match SS3 arrows and home/end", () => {
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
});
it("should match legacy function keys and clear", () => {
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
});
it("should match alt+arrows", () => {
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
assert.strictEqual(matchesKey("\x1bp", "up"), false);
});
it("should match rxvt modifier sequences", () => {
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
});
});
});
describe("parseKey", () => {
describe("Kitty protocol with alternate keys", () => {
it("should return Latin key name when base layout key is present", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base layout 'c'
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
setKittyProtocolActive(false);
});
it("should return key name from codepoint when no base layout", () => {
setKittyProtocolActive(true);
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(parseKey(latinCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(parseKey("\x1b[99;9u"), undefined);
setKittyProtocolActive(false);
});
});
describe("Legacy key parsing", () => {
it("should parse legacy Ctrl+letter", () => {
setKittyProtocolActive(false);
assert.strictEqual(parseKey("\x03"), "ctrl+c");
assert.strictEqual(parseKey("\x04"), "ctrl+d");
});
it("should parse special keys", () => {
assert.strictEqual(parseKey("\x1b"), "escape");
assert.strictEqual(parseKey("\t"), "tab");
assert.strictEqual(parseKey("\r"), "enter");
assert.strictEqual(parseKey("\n"), "enter");
assert.strictEqual(parseKey("\x00"), "ctrl+space");
assert.strictEqual(parseKey(" "), "space");
});
it("should parse arrow keys", () => {
assert.strictEqual(parseKey("\x1b[A"), "up");
assert.strictEqual(parseKey("\x1b[B"), "down");
assert.strictEqual(parseKey("\x1b[C"), "right");
assert.strictEqual(parseKey("\x1b[D"), "left");
});
it("should parse SS3 arrows and home/end", () => {
assert.strictEqual(parseKey("\x1bOA"), "up");
assert.strictEqual(parseKey("\x1bOB"), "down");
assert.strictEqual(parseKey("\x1bOC"), "right");
assert.strictEqual(parseKey("\x1bOD"), "left");
assert.strictEqual(parseKey("\x1bOH"), "home");
assert.strictEqual(parseKey("\x1bOF"), "end");
});
it("should parse legacy function and modifier sequences", () => {
assert.strictEqual(parseKey("\x1bOP"), "f1");
assert.strictEqual(parseKey("\x1b[24~"), "f12");
assert.strictEqual(parseKey("\x1b[E"), "clear");
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
assert.strictEqual(parseKey("\x1bp"), "alt+up");
});
it("should parse double bracket pageUp", () => {
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,626 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component } from "../src/tui.js";
import { TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticOverlay implements Component {
constructor(
private lines: string[],
public requestedWidth?: number,
) {}
render(width: number): string[] {
// Store the width we were asked to render at for verification
this.requestedWidth = width;
return this.lines;
}
invalidate(): void {}
}
class EmptyContent implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
async function renderAndFlush(
tui: TUI,
terminal: VirtualTerminal,
): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
}
describe("TUI overlay options", () => {
describe("width overflow protection", () => {
it("should truncate overlay lines that exceed declared width", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay declares width 20 but renders lines much wider
const overlay = new StaticOverlay(["X".repeat(100)]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash, and no line should exceed terminal width
const viewport = terminal.getViewport();
for (const line of viewport) {
// visibleWidth not available here, but line length is a rough check
// The important thing is it didn't crash
assert.ok(line !== undefined);
}
tui.stop();
});
it("should handle overlay with complex ANSI sequences without crashing", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Simulate complex ANSI content like the crash log showed
const complexLine =
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
" more content ".repeat(10);
const overlay = new StaticOverlay([
complexLine,
complexLine,
complexLine,
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 60 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay composited on styled base content", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with styling
class StyledContent implements Component {
render(width: number): string[] {
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
return [styledLine, styledLine, styledLine];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY"]);
tui.addChild(new StyledContent());
tui.showOverlay(overlay, { width: 20, anchor: "center" });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash and overlay should be visible
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
assert.ok(hasOverlay, "Overlay should be visible");
tui.stop();
});
it("should handle wide characters at overlay boundary", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Wide chars (each takes 2 columns) at the edge of declared width
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
const overlay = new StaticOverlay([wideCharLine]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay positioned at terminal edge", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay positioned at right edge with content that exceeds declared width
const overlay = new StaticOverlay(["X".repeat(50)]);
tui.addChild(new EmptyContent());
// Position at col 60 with width 20 - should fit exactly at right edge
tui.showOverlay(overlay, { col: 60, width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay on base content with OSC sequences", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with OSC 8 hyperlinks (like file paths in agent output)
class HyperlinkContent implements Component {
render(width: number): string[] {
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
return [line, line, line];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
tui.addChild(new HyperlinkContent());
tui.showOverlay(overlay, { anchor: "center", width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash - this was the original bug scenario
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
});
describe("width percentage", () => {
it("should render overlay at percentage of terminal width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 50);
tui.stop();
});
it("should respect minWidth when widthPercent results in smaller width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 30);
tui.stop();
});
});
describe("anchor positioning", () => {
it("should position overlay at top-left", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP-LEFT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.startsWith("TOP-LEFT"),
`Expected TOP-LEFT at start, got: ${viewport[0]}`,
);
tui.stop();
});
it("should position overlay at bottom-right", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BTM-RIGHT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on last row, ending at last column
const lastRow = viewport[23];
assert.ok(
lastRow?.includes("BTM-RIGHT"),
`Expected BTM-RIGHT on last row, got: ${lastRow}`,
);
assert.ok(
lastRow?.trimEnd().endsWith("BTM-RIGHT"),
`Expected BTM-RIGHT at end, got: ${lastRow}`,
);
tui.stop();
});
it("should position overlay at top-center", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["CENTERED"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on first row, centered horizontally
const firstRow = viewport[0];
assert.ok(
firstRow?.includes("CENTERED"),
`Expected CENTERED on first row, got: ${firstRow}`,
);
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
assert.ok(
colIndex >= 30 && colIndex <= 40,
`Expected centered, got col ${colIndex}`,
);
tui.stop();
});
});
describe("margin", () => {
it("should clamp negative margins to zero", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["NEG-MARGIN"]);
tui.addChild(new EmptyContent());
// Negative margins should be treated as 0
tui.showOverlay(overlay, {
anchor: "top-left",
width: 12,
margin: { top: -5, left: -10, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be at row 0, col 0 (negative margins clamped to 0)
assert.ok(
viewport[0]?.startsWith("NEG-MARGIN"),
`Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`,
);
tui.stop();
});
it("should respect margin as number", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on row 5 (not 0) due to margin
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
assert.ok(
viewport[5]?.includes("MARGIN"),
`Expected MARGIN on row 5, got: ${viewport[5]}`,
);
// Should start at col 5 (not 0)
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
it("should respect margin object", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, {
anchor: "top-left",
width: 10,
margin: { top: 2, left: 3, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[2]?.includes("MARGIN"),
`Expected MARGIN on row 2, got: ${viewport[2]}`,
);
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
tui.stop();
});
});
describe("offset", () => {
it("should apply offsetX and offsetY from anchor position", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["OFFSET"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, {
anchor: "top-left",
width: 10,
offsetX: 10,
offsetY: 5,
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[5]?.includes("OFFSET"),
`Expected OFFSET on row 5, got: ${viewport[5]}`,
);
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
tui.stop();
});
});
describe("percentage positioning", () => {
it("should position with rowPercent and colPercent", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["PCT"]);
tui.addChild(new EmptyContent());
// 50% should center both ways
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Find the row with PCT
let foundRow = -1;
for (let i = 0; i < viewport.length; i++) {
if (viewport[i]?.includes("PCT")) {
foundRow = i;
break;
}
}
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
assert.ok(
foundRow >= 10 && foundRow <= 13,
`Expected centered row, got ${foundRow}`,
);
tui.stop();
});
it("rowPercent 0 should position at top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "0%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("TOP"),
`Expected TOP on row 0, got: ${viewport[0]}`,
);
tui.stop();
});
it("rowPercent 100 should position at bottom", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BOTTOM"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "100%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[23]?.includes("BOTTOM"),
`Expected BOTTOM on last row, got: ${viewport[23]}`,
);
tui.stop();
});
});
describe("maxHeight", () => {
it("should truncate overlay to maxHeight", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay([
"Line 1",
"Line 2",
"Line 3",
"Line 4",
"Line 5",
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: 3 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("Line 1"), "Should include Line 1");
assert.ok(content.includes("Line 2"), "Should include Line 2");
assert.ok(content.includes("Line 3"), "Should include Line 3");
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
tui.stop();
});
it("should truncate overlay to maxHeightPercent", async () => {
const terminal = new VirtualTerminal(80, 10);
const tui = new TUI(terminal);
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
const overlay = new StaticOverlay([
"L1",
"L2",
"L3",
"L4",
"L5",
"L6",
"L7",
"L8",
"L9",
"L10",
]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("L1"), "Should include L1");
assert.ok(content.includes("L5"), "Should include L5");
assert.ok(!content.includes("L6"), "Should NOT include L6");
tui.stop();
});
});
describe("absolute positioning", () => {
it("row and col should override anchor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["ABSOLUTE"]);
tui.addChild(new EmptyContent());
// Even with bottom-right anchor, row/col should win
tui.showOverlay(overlay, {
anchor: "bottom-right",
row: 3,
col: 5,
width: 10,
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(
viewport[3]?.includes("ABSOLUTE"),
`Expected ABSOLUTE on row 3, got: ${viewport[3]}`,
);
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
});
describe("stacked overlays", () => {
it("should render multiple overlays with later ones on top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// First overlay at top-left
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
// Second overlay at top-left (should cover part of first)
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Second overlay should be visible (on top)
assert.ok(
viewport[0]?.includes("SECOND"),
`Expected SECOND on row 0, got: ${viewport[0]}`,
);
// Part of first overlay might still be visible after SECOND
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
tui.stop();
});
it("should handle overlays at different positions without interference", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Overlay at top-left
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
// Overlay at bottom-right
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Both should be visible
assert.ok(
viewport[0]?.includes("TOP-LEFT"),
`Expected TOP-LEFT on row 0, got: ${viewport[0]}`,
);
assert.ok(
viewport[23]?.includes("BTM-RIGHT"),
`Expected BTM-RIGHT on row 23, got: ${viewport[23]}`,
);
tui.stop();
});
it("should properly hide overlays in stack order", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Show two overlays
const overlay1 = new StaticOverlay(["FIRST"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
// Second should be visible
let viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("SECOND"),
"SECOND should be visible initially",
);
// Hide top overlay
tui.hideOverlay();
await renderAndFlush(tui, terminal);
// First should now be visible
viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("FIRST"),
"FIRST should be visible after hiding SECOND",
);
tui.stop();
});
});
});

View file

@ -0,0 +1,60 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class SimpleContent implements Component {
constructor(private lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate() {}
}
class SimpleOverlay implements Component {
render(): string[] {
return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"];
}
invalidate() {}
}
describe("TUI overlay with short content", () => {
it("should render overlay when content is shorter than terminal height", async () => {
// Terminal has 24 rows, but content only has 3 lines
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Only 3 lines of content
tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"]));
// Show overlay centered - should be around row 10 in a 24-row terminal
const overlay = new SimpleOverlay();
tui.showOverlay(overlay);
// Trigger render
tui.start();
await new Promise((r) => process.nextTick(r));
await terminal.flush();
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));
console.log("Terminal rows:", terminal.rows);
console.log("Content lines: 3");
console.log("Overlay visible:", hasOverlay);
if (!hasOverlay) {
console.log("\nViewport contents:");
for (let i = 0; i < viewport.length; i++) {
console.log(` [${i}]: "${viewport[i]}"`);
}
}
assert.ok(
hasOverlay,
"Overlay should be visible when content is shorter than terminal",
);
tui.stop();
});
});

View file

@ -0,0 +1,60 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("regional indicator width regression", () => {
it("treats partial flag grapheme as full-width to avoid streaming render drift", () => {
// Repro context:
// During streaming, "🇨🇳" often appears as an intermediate "🇨" first.
// If "🇨" is measured as width 1 while terminal renders it as width 2,
// differential rendering can drift and leave stale characters on screen.
const partialFlag = "🇨";
const listLine = " - 🇨";
assert.strictEqual(visibleWidth(partialFlag), 2);
assert.strictEqual(visibleWidth(listLine), 10);
});
it("wraps intermediate partial-flag list line before overflow", () => {
// Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10).
// This must wrap to avoid terminal auto-wrap mismatch.
const wrapped = wrapTextWithAnsi(" - 🇨", 9);
assert.strictEqual(wrapped.length, 2);
assert.strictEqual(visibleWidth(wrapped[0] || ""), 7);
assert.strictEqual(visibleWidth(wrapped[1] || ""), 2);
});
it("treats all regional-indicator singleton graphemes as width 2", () => {
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
const regionalIndicator = String.fromCodePoint(cp);
assert.strictEqual(
visibleWidth(regionalIndicator),
2,
`Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`,
);
}
});
it("keeps full flag pairs at width 2", () => {
const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"];
for (const flag of samples) {
assert.strictEqual(
visibleWidth(flag),
2,
`Expected ${flag} to be width 2`,
);
}
});
it("keeps common streaming emoji intermediates at stable width", () => {
const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨‍💻", "🏳️‍🌈"];
for (const sample of samples) {
assert.strictEqual(
visibleWidth(sample),
2,
`Expected ${sample} to be width 2`,
);
}
});
});

View file

@ -0,0 +1,30 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { SelectList } from "../src/components/select-list.js";
const testTheme = {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
noMatch: (text: string) => text,
};
describe("SelectList", () => {
it("normalizes multiline descriptions to single line", () => {
const items = [
{
value: "test",
label: "test",
description: "Line one\nLine two\nLine three",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(100);
assert.ok(rendered.length > 0);
assert.ok(!rendered[0].includes("\n"));
assert.ok(rendered[0].includes("Line one Line two Line three"));
});
});

View file

@ -0,0 +1,450 @@
/**
* Tests for StdinBuffer
*
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
* MIT License - Copyright (c) 2025 opentui
*/
import assert from "node:assert";
import { beforeEach, describe, it } from "node:test";
import { StdinBuffer } from "../src/stdin-buffer.js";
describe("StdinBuffer", () => {
let buffer: StdinBuffer;
let emittedSequences: string[];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
});
// Helper to process data through the buffer
function processInput(data: string | Buffer): void {
buffer.process(data);
}
// Helper to wait for async operations
async function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Regular Characters", () => {
it("should pass through regular characters immediately", () => {
processInput("a");
assert.deepStrictEqual(emittedSequences, ["a"]);
});
it("should pass through multiple regular characters", () => {
processInput("abc");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
});
it("should handle unicode characters", () => {
processInput("hello 世界");
assert.deepStrictEqual(emittedSequences, [
"h",
"e",
"l",
"l",
"o",
" ",
"世",
"界",
]);
});
});
describe("Complete Escape Sequences", () => {
it("should pass through complete mouse SGR sequences", () => {
const mouseSeq = "\x1b[<35;20;5m";
processInput(mouseSeq);
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
});
it("should pass through complete arrow key sequences", () => {
const upArrow = "\x1b[A";
processInput(upArrow);
assert.deepStrictEqual(emittedSequences, [upArrow]);
});
it("should pass through complete function key sequences", () => {
const f1 = "\x1b[11~";
processInput(f1);
assert.deepStrictEqual(emittedSequences, [f1]);
});
it("should pass through meta key sequences", () => {
const metaA = "\x1ba";
processInput(metaA);
assert.deepStrictEqual(emittedSequences, [metaA]);
});
it("should pass through SS3 sequences", () => {
const ss3 = "\x1bOA";
processInput(ss3);
assert.deepStrictEqual(emittedSequences, [ss3]);
});
});
describe("Partial Escape Sequences", () => {
it("should buffer incomplete mouse SGR sequence", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b");
processInput("[<35");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should buffer incomplete CSI sequence", () => {
processInput("\x1b[");
assert.deepStrictEqual(emittedSequences, []);
processInput("1;");
assert.deepStrictEqual(emittedSequences, []);
processInput("5H");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
});
it("should buffer split across many chunks", () => {
processInput("\x1b");
processInput("[");
processInput("<");
processInput("3");
processInput("5");
processInput(";");
processInput("2");
processInput("0");
processInput(";");
processInput("5");
processInput("m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should flush incomplete sequence after timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Mixed Content", () => {
it("should handle characters followed by escape sequence", () => {
processInput("abc\x1b[A");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
});
it("should handle escape sequence followed by characters", () => {
processInput("\x1b[Aabc");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
});
it("should handle multiple complete sequences", () => {
processInput("\x1b[A\x1b[B\x1b[C");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
});
it("should handle partial sequence with preceding characters", () => {
processInput("abc\x1b[<35");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, [
"a",
"b",
"c",
"\x1b[<35;20;5m",
]);
});
});
describe("Kitty Keyboard Protocol", () => {
it("should handle Kitty CSI u press events", () => {
// Press 'a' in Kitty protocol
processInput("\x1b[97u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
});
it("should handle Kitty CSI u release events", () => {
// Release 'a' in Kitty protocol
processInput("\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
});
it("should handle batched Kitty press and release", () => {
// Press 'a', release 'a' batched together (common over SSH)
processInput("\x1b[97u\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
});
it("should handle multiple batched Kitty events", () => {
// Press 'a', release 'a', press 'b', release 'b'
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
assert.deepStrictEqual(emittedSequences, [
"\x1b[97u",
"\x1b[97;1:3u",
"\x1b[98u",
"\x1b[98;1:3u",
]);
});
it("should handle Kitty arrow keys with event type", () => {
// Up arrow press with event type
processInput("\x1b[1;1:1A");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
});
it("should handle Kitty functional keys with event type", () => {
// Delete key release
processInput("\x1b[3;1:3~");
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
});
it("should handle plain characters mixed with Kitty sequences", () => {
// Plain 'a' followed by Kitty release
processInput("a\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
});
it("should handle Kitty sequence followed by plain characters", () => {
processInput("\x1b[97ua");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]);
});
it("should handle rapid typing simulation with Kitty protocol", () => {
// Simulates typing "hi" quickly with releases interleaved
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
assert.deepStrictEqual(emittedSequences, [
"\x1b[104u",
"\x1b[104;1:3u",
"\x1b[105u",
"\x1b[105;1:3u",
]);
});
});
describe("Mouse Events", () => {
it("should handle mouse press event", () => {
processInput("\x1b[<0;10;5M");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
});
it("should handle mouse release event", () => {
processInput("\x1b[<0;10;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
});
it("should handle mouse move event", () => {
processInput("\x1b[<35;20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should handle split mouse events", () => {
processInput("\x1b[<3");
processInput("5;1");
processInput("5;");
processInput("10m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
});
it("should handle multiple mouse events", () => {
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
assert.deepStrictEqual(emittedSequences, [
"\x1b[<35;1;1m",
"\x1b[<35;2;2m",
"\x1b[<35;3;3m",
]);
});
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
processInput("\x1b[M abc");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
});
it("should buffer incomplete old-style mouse sequence", () => {
processInput("\x1b[M");
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
processInput(" a");
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
});
});
describe("Edge Cases", () => {
it("should handle empty input", () => {
processInput("");
// Empty string emits an empty data event
assert.deepStrictEqual(emittedSequences, [""]);
});
it("should handle lone escape character with timeout", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
// After timeout, should emit
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
});
it("should handle lone escape character with explicit flush", () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b"]);
});
it("should handle buffer input", () => {
processInput(Buffer.from("\x1b[A"));
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
});
it("should handle very long sequences", () => {
const longSeq = `\x1b[${"1;".repeat(50)}H`;
processInput(longSeq);
assert.deepStrictEqual(emittedSequences, [longSeq]);
});
});
describe("Flush", () => {
it("should flush incomplete sequences", () => {
processInput("\x1b[<35");
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should return empty array if nothing to flush", () => {
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, []);
});
it("should emit flushed data via timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout to flush
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Clear", () => {
it("should clear buffered content without emitting", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.clear();
assert.strictEqual(buffer.getBuffer(), "");
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Bracketed Paste", () => {
let emittedPaste: string[] = [];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
// Collect paste events
emittedPaste = [];
buffer.on("paste", (data) => {
emittedPaste.push(data);
});
});
it("should emit paste event for complete bracketed paste", () => {
const pasteStart = "\x1b[200~";
const pasteEnd = "\x1b[201~";
const content = "hello world";
processInput(pasteStart + content + pasteEnd);
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
});
it("should handle paste arriving in chunks", () => {
processInput("\x1b[200~");
assert.deepStrictEqual(emittedPaste, []);
processInput("hello ");
assert.deepStrictEqual(emittedPaste, []);
processInput("world\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with input before and after", () => {
processInput("a");
processInput("\x1b[200~pasted\x1b[201~");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
assert.deepStrictEqual(emittedPaste, ["pasted"]);
});
it("should handle paste with newlines", () => {
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with unicode", () => {
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Destroy", () => {
it("should clear buffer on destroy", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.destroy();
assert.strictEqual(buffer.getBuffer(), "");
});
it("should clear pending timeouts on destroy", async () => {
processInput("\x1b[<35");
buffer.destroy();
// Wait longer than timeout
await wait(15);
// Should not have emitted anything
assert.deepStrictEqual(emittedSequences, []);
});
});
});

View file

@ -0,0 +1,167 @@
/**
* Tests for terminal image detection and line handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { isImageLine } from "../src/terminal-image.js";
describe("isImageLine", () => {
describe("iTerm2 image protocol", () => {
it("should detect iTerm2 image escape sequence at start of line", () => {
// iTerm2 image escape sequence: ESC ]1337;File=...
const iterm2ImageLine =
"\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07";
assert.strictEqual(isImageLine(iterm2ImageLine), true);
});
it("should detect iTerm2 image escape sequence with text before it", () => {
// Simulating a line that has text then image data (bug scenario)
const lineWithTextAndImage =
"Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text";
assert.strictEqual(isImageLine(lineWithTextAndImage), true);
});
it("should detect iTerm2 image escape sequence in middle of long line", () => {
// Simulate a very long line with image data in the middle
const longLineWithImage =
"Text before image..." +
"\x1b]1337;File=inline=1:verylongbase64data==" +
"...text after";
assert.strictEqual(isImageLine(longLineWithImage), true);
});
it("should detect iTerm2 image escape sequence at end of line", () => {
const lineWithImageAtEnd =
"Regular text ending with \x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImageAtEnd), true);
});
it("should detect minimal iTerm2 image escape sequence", () => {
const minimalImageLine = "\x1b]1337;File=:\x07";
assert.strictEqual(isImageLine(minimalImageLine), true);
});
});
describe("Kitty image protocol", () => {
it("should detect Kitty image escape sequence at start of line", () => {
// Kitty image escape sequence: ESC _G
const kittyImageLine =
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(kittyImageLine), true);
});
it("should detect Kitty image escape sequence with text before it", () => {
// Bug scenario: text + image data in same line
const lineWithTextAndKittyImage =
"Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true);
});
it("should detect Kitty image escape sequence with padding", () => {
// Kitty protocol adds padding to escape sequences
const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ ";
assert.strictEqual(isImageLine(kittyWithPadding), true);
});
});
describe("Bug regression tests", () => {
it("should detect image sequences in very long lines (304k+ chars)", () => {
// This simulates the crash scenario: a line with 304,401 chars
// containing image escape sequences somewhere
const base64Char = "A".repeat(100); // 100 chars of base64-like data
const imageSequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a long line with image sequence
const longLine =
"Text prefix " +
imageSequence +
base64Char.repeat(3000) + // ~300,000 chars
" suffix";
assert.strictEqual(longLine.length > 300000, true);
assert.strictEqual(isImageLine(longLine), true);
});
it("should detect image sequences when terminal doesn't support images", () => {
// The bug occurred when getImageEscapePrefix() returned null
// isImageLine should still detect image sequences regardless
const lineWithImage =
"Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImage), true);
});
it("should detect image sequences with ANSI codes before them", () => {
// Text might have ANSI styling before image data
const lineWithAnsiAndImage =
"\x1b[31mError output \x1b]1337;File=inline=1:image==\x07";
assert.strictEqual(isImageLine(lineWithAnsiAndImage), true);
});
it("should detect image sequences with ANSI codes after them", () => {
const lineWithImageAndAnsi =
"\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset";
assert.strictEqual(isImageLine(lineWithImageAndAnsi), true);
});
});
describe("Negative cases - lines without images", () => {
it("should not detect images in plain text lines", () => {
const plainText =
"This is just a regular text line without any escape sequences";
assert.strictEqual(isImageLine(plainText), false);
});
it("should not detect images in lines with only ANSI codes", () => {
const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
assert.strictEqual(isImageLine(ansiText), false);
});
it("should not detect images in lines with cursor movement codes", () => {
const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up";
assert.strictEqual(isImageLine(cursorCodes), false);
});
it("should not detect images in lines with partial iTerm2 sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence =
"Some text with ]1337;File but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in lines with partial Kitty sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence = "Some text with _G but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in empty lines", () => {
assert.strictEqual(isImageLine(""), false);
});
it("should not detect images in lines with newlines only", () => {
assert.strictEqual(isImageLine("\n"), false);
assert.strictEqual(isImageLine("\n\n"), false);
});
});
describe("Mixed content scenarios", () => {
it("should detect images when line has both Kitty and iTerm2 sequences", () => {
const mixedLine =
"Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07";
assert.strictEqual(isImageLine(mixedLine), true);
});
it("should detect image in line with multiple text and image segments", () => {
const complexLine =
"Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end";
assert.strictEqual(isImageLine(complexLine), true);
});
it("should not falsely detect image in line with file path containing keywords", () => {
// File path might contain "1337" or "File" but without escape sequences
const filePathLine = "/path/to/File_1337_backup/image.jpg";
assert.strictEqual(isImageLine(filePathLine), false);
});
});
});

View file

@ -0,0 +1,42 @@
/**
* Default themes for TUI tests using chalk
*/
import { Chalk } from "chalk";
import type {
EditorTheme,
MarkdownTheme,
SelectListTheme,
} from "../src/index.js";
const chalk = new Chalk({ level: 3 });
export const defaultSelectListTheme: SelectListTheme = {
selectedPrefix: (text: string) => chalk.blue(text),
selectedText: (text: string) => chalk.bold(text),
description: (text: string) => chalk.dim(text),
scrollInfo: (text: string) => chalk.dim(text),
noMatch: (text: string) => chalk.dim(text),
};
export const defaultMarkdownTheme: MarkdownTheme = {
heading: (text: string) => chalk.bold.cyan(text),
link: (text: string) => chalk.blue(text),
linkUrl: (text: string) => chalk.dim(text),
code: (text: string) => chalk.yellow(text),
codeBlock: (text: string) => chalk.green(text),
codeBlockBorder: (text: string) => chalk.dim(text),
quote: (text: string) => chalk.italic(text),
quoteBorder: (text: string) => chalk.dim(text),
hr: (text: string) => chalk.dim(text),
listBullet: (text: string) => chalk.cyan(text),
bold: (text: string) => chalk.bold(text),
italic: (text: string) => chalk.italic(text),
strikethrough: (text: string) => chalk.strikethrough(text),
underline: (text: string) => chalk.underline(text),
};
export const defaultEditorTheme: EditorTheme = {
borderColor: (text: string) => chalk.dim(text),
selectList: defaultSelectListTheme,
};

View file

@ -0,0 +1,133 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Chalk } from "chalk";
import { TruncatedText } from "../src/components/truncated-text.js";
import { visibleWidth } from "../src/utils.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
describe("TruncatedText component", () => {
it("pads output lines to exactly match width", () => {
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(50);
// Should have exactly one content line (no vertical padding)
assert.strictEqual(lines.length, 1);
// Line should be exactly 50 visible characters
const visibleLen = visibleWidth(lines[0]);
assert.strictEqual(visibleLen, 50);
});
it("pads output with vertical padding lines to width", () => {
const text = new TruncatedText("Hello", 0, 2);
const lines = text.render(40);
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
assert.strictEqual(lines.length, 5);
// All lines should be exactly 40 characters
for (const line of lines) {
assert.strictEqual(visibleWidth(line), 40);
}
});
it("truncates long text and pads to width", () => {
const longText =
"This is a very long piece of text that will definitely exceed the available width";
const text = new TruncatedText(longText, 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
// Should be exactly 30 characters
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
});
it("preserves ANSI codes in output and pads correctly", () => {
const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`;
const text = new TruncatedText(styledText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
// Should be exactly 40 visible characters (ANSI codes don't count)
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should preserve the color codes
assert.ok(lines[0].includes("\x1b["));
});
it("truncates styled text and adds reset code before ellipsis", () => {
const longStyledText = chalk.red(
"This is a very long red text that will be truncated",
);
const text = new TruncatedText(longStyledText, 1, 0);
const lines = text.render(20);
assert.strictEqual(lines.length, 1);
// Should be exactly 20 visible characters
assert.strictEqual(visibleWidth(lines[0]), 20);
// Should contain reset code before ellipsis
assert.ok(lines[0].includes("\x1b[0m..."));
});
it("handles text that fits exactly", () => {
// With paddingX=1, available width is 30-2=28
// "Hello world" is 11 chars, fits comfortably
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should NOT contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(!stripped.includes("..."));
});
it("handles empty text", () => {
const text = new TruncatedText("", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
});
it("stops at newline and only shows first line", () => {
const multilineText = "First line\nSecond line\nThird line";
const text = new TruncatedText(multilineText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should only contain "First line"
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
assert.ok(stripped.includes("First line"));
assert.ok(!stripped.includes("Second line"));
assert.ok(!stripped.includes("Third line"));
});
it("truncates first line even with newlines in text", () => {
const longMultilineText =
"This is a very long first line that needs truncation\nSecond line";
const text = new TruncatedText(longMultilineText, 1, 0);
const lines = text.render(25);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 25);
// Should contain ellipsis and not second line
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
assert.ok(!stripped.includes("Second line"));
});
});

View file

@ -0,0 +1,79 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticLines implements Component {
constructor(private readonly lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
class StaticOverlay implements Component {
constructor(private readonly line: string) {}
render(): string[] {
return [this.line];
}
invalidate(): void {}
}
function getCellItalic(
terminal: VirtualTerminal,
row: number,
col: number,
): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
async function renderAndFlush(
tui: TUI,
terminal: VirtualTerminal,
): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.flush();
}
describe("TUI overlay compositing", () => {
it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("should not leak styles when overlay slicing drops trailing SGR resets", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
});

View file

@ -0,0 +1,409 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class TestComponent implements Component {
lines: string[] = [];
render(_width: number): string[] {
return this.lines;
}
invalidate(): void {}
}
function getCellItalic(
terminal: VirtualTerminal,
row: number,
col: number,
): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
describe("TUI resize handling", () => {
it("triggers full re-render when terminal height changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Resize height
terminal.resize(40, 15);
await terminal.flush();
// Should have triggered a full redraw
assert.ok(
tui.fullRedraws > initialRedraws,
"Height change should trigger full redraw",
);
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
"Content preserved after height change",
);
tui.stop();
});
it("triggers full re-render when terminal width changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Resize width
terminal.resize(60, 10);
await terminal.flush();
// Should have triggered a full redraw
assert.ok(
tui.fullRedraws > initialRedraws,
"Width change should trigger full redraw",
);
tui.stop();
});
});
describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
// Start with many lines
component.lines = [
"Line 0",
"Line 1",
"Line 2",
"Line 3",
"Line 4",
"Line 5",
];
tui.start();
await terminal.flush();
const initialRedraws = tui.fullRedraws;
// Shrink to fewer lines
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.flush();
// Should have triggered a full redraw to clear empty rows
assert.ok(
tui.fullRedraws > initialRedraws,
"Content shrinkage should trigger full redraw",
);
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
// Lines below should be empty (cleared)
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
tui.stop();
});
it("handles shrink to single line", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Shrink to single line
component.lines = ["Only line"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
it("handles shrink to empty", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
// Shrink to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
// All lines should be empty
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
});
describe("TUI differential rendering", () => {
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render: 5 identical lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.flush();
// cursorRow should be 2 (last line of new content)
// Verify by doing another render with a change on line 1
component.lines = ["Line 0", "CHANGED", "Line 2"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
// Line 1 should show "CHANGED", proving cursor tracking was correct
assert.ok(
viewport[1]?.includes("CHANGED"),
`Expected "CHANGED" on line 1, got: ${viewport[1]}`,
);
tui.stop();
});
it("renders correctly when only a middle line changes (spinner case)", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render
component.lines = ["Header", "Working...", "Footer"];
tui.start();
await terminal.flush();
// Simulate spinner animation - only middle line changes
const spinnerFrames = ["|", "/", "-", "\\"];
for (const frame of spinnerFrames) {
component.lines = ["Header", `Working ${frame}`, "Footer"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Header"),
`Header preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes(`Working ${frame}`),
`Spinner updated: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Footer"),
`Footer preserved: ${viewport[2]}`,
);
}
tui.stop();
});
it("resets styles after each rendered line", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["\x1b[3mItalic", "Plain"];
tui.start();
await terminal.flush();
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("renders correctly when first line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Change only first line
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("CHANGED"),
`First line changed: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("Line 1"),
`Line 1 preserved: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("Line 3"),
`Line 3 preserved: ${viewport[3]}`,
);
tui.stop();
});
it("renders correctly when last line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.flush();
// Change only last line
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
`Line 0 preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("Line 1"),
`Line 1 preserved: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("CHANGED"),
`Last line changed: ${viewport[3]}`,
);
tui.stop();
});
it("renders correctly when multiple non-adjacent lines change", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.flush();
// Change lines 1 and 3, keep 0, 2, 4 the same
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
tui.requestRender();
await terminal.flush();
const viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("Line 0"),
`Line 0 preserved: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("CHANGED 1"),
`Line 1 changed: ${viewport[1]}`,
);
assert.ok(
viewport[2]?.includes("Line 2"),
`Line 2 preserved: ${viewport[2]}`,
);
assert.ok(
viewport[3]?.includes("CHANGED 3"),
`Line 3 changed: ${viewport[3]}`,
);
assert.ok(
viewport[4]?.includes("Line 4"),
`Line 4 preserved: ${viewport[4]}`,
);
tui.stop();
});
it("handles transition from content to empty and back to content", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Start with content
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.flush();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
// Clear to empty
component.lines = [];
tui.requestRender();
await terminal.flush();
// Add content back - this should work correctly even after empty state
component.lines = ["New Line 0", "New Line 1"];
tui.requestRender();
await terminal.flush();
viewport = terminal.getViewport();
assert.ok(
viewport[0]?.includes("New Line 0"),
`New content rendered: ${viewport[0]}`,
);
assert.ok(
viewport[1]?.includes("New Line 1"),
`New content line 1: ${viewport[1]}`,
);
tui.stop();
});
});

View file

@ -0,0 +1,113 @@
/**
* TUI viewport overwrite repro
*
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
*
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
* tmux new-session -d -s tui-bug -x 80 -y 12
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
* tmux attach -t tui-bug
*
* Expected behavior:
* - PRE-TOOL lines remain visible above tool output.
* - POST-TOOL lines append after tool output without overwriting earlier content.
*
* Actual behavior (bug):
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
*/
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
class Lines implements Component {
private lines: string[] = [];
set(lines: string[]): void {
this.lines = lines;
}
append(lines: string[]): void {
this.lines.push(...lines);
}
render(width: number): string[] {
return this.lines.map((line) => {
if (line.length > width) return line.slice(0, width);
return line.padEnd(width, " ");
});
}
invalidate(): void {}
}
async function streamLines(
buffer: Lines,
label: string,
count: number,
delayMs: number,
ui: TUI,
): Promise<void> {
for (let i = 1; i <= count; i += 1) {
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
ui.requestRender();
await sleep(delayMs);
}
}
async function main(): Promise<void> {
const ui = new TUI(new ProcessTerminal());
const buffer = new Lines();
ui.addChild(buffer);
ui.start();
const height = ui.terminal.rows;
const preCount = height + 8; // Ensure content exceeds viewport
const toolCount = height + 12; // Tool output pushes further into scrollback
const postCount = 6;
buffer.set([
"TUI viewport overwrite repro",
`Viewport rows detected: ${height}`,
"(Resize to ~8-12 rows for best repro)",
"",
"=== PRE-TOOL STREAM ===",
]);
ui.requestRender();
await sleep(300);
// Phase 1: Stream pre-tool text until viewport is exceeded.
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
// Phase 2: Simulate tool call pause and tool output.
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
ui.requestRender();
await sleep(700);
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
// Phase 3: Post-tool streaming. This is where overwrite often appears.
buffer.append(["", "=== POST-TOOL STREAM ==="]);
ui.requestRender();
await sleep(300);
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
// Leave the output visible briefly, then restore terminal state.
await sleep(1500);
ui.stop();
}
main().catch((error) => {
// Ensure terminal is restored if something goes wrong.
try {
const ui = new TUI(new ProcessTerminal());
ui.stop();
} catch {
// Ignore restore errors.
}
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
});

View file

@ -0,0 +1,209 @@
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;
/**
* Virtual terminal for testing using xterm.js for accurate terminal emulation
*/
export class VirtualTerminal implements Terminal {
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
constructor(columns = 80, rows = 24) {
this._columns = columns;
this._rows = rows;
// Create xterm instance with specified dimensions
this.xterm = new XtermTerminal({
cols: columns,
rows: rows,
// Disable all interactive features for testing
disableStdin: true,
allowProposedApi: true,
});
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Enable bracketed paste mode for consistency with ProcessTerminal
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no stdin to drain
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
write(data: string): void {
this.xterm.write(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
// Virtual terminal always reports Kitty protocol as active for testing
return true;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
this.xterm.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
this.xterm.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
this.xterm.write("\x1b[?25l");
}
showCursor(): void {
this.xterm.write("\x1b[?25h");
}
clearLine(): void {
this.xterm.write("\x1b[K");
}
clearFromCursor(): void {
this.xterm.write("\x1b[J");
}
clearScreen(): void {
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
this.xterm.write(`\x1b]0;${title}\x07`);
}
// Test-specific methods not in Terminal interface
/**
* Simulate keyboard input
*/
sendInput(data: string): void {
if (this.inputHandler) {
this.inputHandler(data);
}
}
/**
* Resize the terminal
*/
resize(columns: number, rows: number): void {
this._columns = columns;
this._rows = rows;
this.xterm.resize(columns, rows);
if (this.resizeHandler) {
this.resizeHandler();
}
}
/**
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
*/
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>((resolve) => {
this.xterm.write("", () => resolve());
});
}
/**
* Flush and get viewport - convenience method for tests
*/
async flushAndGetViewport(): Promise<string[]> {
await this.flush();
return this.getViewport();
}
/**
* Get the visible viewport (what's currently on screen)
* Note: You should use getViewportAfterWrite() for testing after writing data
*/
getViewport(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get only the visible lines (viewport)
for (let i = 0; i < this.xterm.rows; i++) {
const line = buffer.getLine(buffer.viewportY + i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the entire scroll buffer
*/
getScrollBuffer(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get all lines in the buffer (including scrollback)
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Clear the terminal viewport
*/
clear(): void {
this.xterm.clear();
}
/**
* Reset the terminal completely
*/
reset(): void {
this.xterm.reset();
}
/**
* Get cursor position
*/
getCursorPosition(): { x: number; y: number } {
const buffer = this.xterm.buffer.active;
return {
x: buffer.cursorX,
y: buffer.cursorY,
};
}
}

View file

@ -0,0 +1,158 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("wrapTextWithAnsi", () => {
describe("underline styling", () => {
it("should not apply underline style before the styled text", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url = "https://example.com/very/long/path/that/will/wrap";
const text = `read this thread ${underlineOn}${url}${underlineOff}`;
const wrapped = wrapTextWithAnsi(text, 40);
// First line should NOT contain underline code - it's just "read this thread"
assert.strictEqual(wrapped[0], "read this thread");
// Second line should start with underline, have URL content
assert.strictEqual(wrapped[1].startsWith(underlineOn), true);
assert.ok(wrapped[1].includes("https://"));
});
it("should not have whitespace before underline reset code", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
assert.ok(!wrapped[0].includes(` ${underlineOff}`));
});
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url =
"https://example.com/very/long/path/that/will/definitely/wrap";
const text = `prefix ${underlineOn}${url}${underlineOff} suffix`;
const wrapped = wrapTextWithAnsi(text, 30);
// Middle lines (with underlined content) should end with underline-off, not full reset
// Line 1 and 2 contain underlined URL parts
for (let i = 1; i < wrapped.length - 1; i++) {
const line = wrapped[i];
if (line.includes(underlineOn)) {
// Should end with underline off, NOT full reset
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("background color preservation", () => {
it("should preserve background color across wrapped lines without full reset", () => {
const bgBlue = "\x1b[44m";
const reset = "\x1b[0m";
const text = `${bgBlue}hello world this is blue background text${reset}`;
const wrapped = wrapTextWithAnsi(text, 15);
// Each line should have background color
for (const line of wrapped) {
assert.ok(line.includes(bgBlue));
}
// Middle lines should NOT end with full reset (kills background for padding)
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const reset = "\x1b[0m";
const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`;
const wrapped = wrapTextWithAnsi(text, 20);
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
for (const line of wrapped) {
const hasBgColor =
line.includes("[41m") ||
line.includes(";41m") ||
line.includes("[41;");
assert.ok(hasBgColor);
}
// Lines with underlined content should use underline-off at end, not full reset
for (let i = 0; i < wrapped.length - 1; i++) {
const line = wrapped[i];
// If this line has underline on, it should end with underline off (not full reset)
if (
(line.includes("[4m") ||
line.includes("[4;") ||
line.includes(";4m")) &&
!line.includes(underlineOff)
) {
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("basic wrapping", () => {
it("should wrap plain text correctly", () => {
const text = "hello world this is a test";
const wrapped = wrapTextWithAnsi(text, 10);
assert.ok(wrapped.length > 1);
for (const line of wrapped) {
assert.ok(visibleWidth(line) <= 10);
}
});
it("should ignore OSC 133 semantic markers in visible width", () => {
const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
assert.strictEqual(visibleWidth(text), 5);
});
it("should ignore OSC sequences terminated with ST in visible width", () => {
const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\";
assert.strictEqual(visibleWidth(text), 5);
});
it("should treat isolated regional indicators as width 2", () => {
assert.strictEqual(visibleWidth("🇨"), 2);
assert.strictEqual(visibleWidth("🇨🇳"), 2);
});
it("should truncate trailing whitespace that exceeds width", () => {
const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1);
assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);
});
it("should preserve color codes across wraps", () => {
const red = "\x1b[31m";
const reset = "\x1b[0m";
const text = `${red}hello world this is red${reset}`;
const wrapped = wrapTextWithAnsi(text, 10);
// Each continuation line should start with red code
for (let i = 1; i < wrapped.length; i++) {
assert.strictEqual(wrapped[i].startsWith(red), true);
}
// Middle lines should not end with full reset
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
});
});