Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
166
packages/tui/test/alt-screen-large-transcript-bench.ts
Normal file
166
packages/tui/test/alt-screen-large-transcript-bench.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Alternate-screen large-transcript benchmark.
|
||||
*
|
||||
* Mirrors the fullscreen transcript shape used by the coding agent:
|
||||
*
|
||||
* ScrollView
|
||||
* └── document Container
|
||||
* ├── header Container
|
||||
* ├── resources Container
|
||||
* └── chat Container
|
||||
* └── 2,500 alternating user/assistant Markdown components
|
||||
*
|
||||
* Measures warm steady frames, one-line scrolling, streaming updates to the
|
||||
* final assistant message, and width changes that invalidate Markdown caches.
|
||||
*
|
||||
* Run from the repository root:
|
||||
* node --experimental-strip-types packages/tui/test/alt-screen-large-transcript-bench.ts
|
||||
*/
|
||||
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import { Container } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
|
||||
const COLUMNS = 80;
|
||||
const ROWS = 50;
|
||||
const COMPONENT_COUNT = 2_500;
|
||||
const WARMUP_FRAMES = 20;
|
||||
|
||||
const theme: MarkdownTheme = {
|
||||
heading: (text) => text,
|
||||
link: (text) => text,
|
||||
linkUrl: (text) => text,
|
||||
code: (text) => text,
|
||||
codeBlock: (text) => text,
|
||||
codeBlockBorder: (text) => text,
|
||||
quote: (text) => text,
|
||||
quoteBorder: (text) => text,
|
||||
hr: (text) => text,
|
||||
listBullet: (text) => text,
|
||||
bold: (text) => text,
|
||||
italic: (text) => text,
|
||||
strikethrough: (text) => text,
|
||||
underline: (text) => text,
|
||||
};
|
||||
|
||||
class NullTerminal implements Terminal {
|
||||
columns = COLUMNS;
|
||||
rows = ROWS;
|
||||
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(_data: string): void {}
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
const userMarkdown = [
|
||||
"## User request",
|
||||
"",
|
||||
"Please inspect this implementation carefully and report the relevant behavior.",
|
||||
"",
|
||||
"- preserve correctness",
|
||||
"- include concrete evidence",
|
||||
].join("\n");
|
||||
|
||||
const assistantMarkdown = [
|
||||
"## Analysis",
|
||||
"",
|
||||
"This response contains representative prose with **inline styles**, a [link](https://example.com), and enough words to wrap across terminal rows at realistic widths. The implementation should preserve all observable behavior while avoiding unnecessary allocation.",
|
||||
"",
|
||||
"1. First item with explanatory text and a concrete example.",
|
||||
"2. Second item with more explanatory text and another example.",
|
||||
"3. Third item that ensures list rendering is represented.",
|
||||
"",
|
||||
"```ts",
|
||||
"function render(width: number): string[] {",
|
||||
" const lines: string[] = [];",
|
||||
" for (const child of children) {",
|
||||
" const childLines = child.render(width);",
|
||||
" for (const line of childLines) lines.push(line);",
|
||||
" }",
|
||||
" const height = lines.length;",
|
||||
" const start = Math.max(0, from);",
|
||||
" const end = Math.min(height, start + count);",
|
||||
" const visible = lines.slice(start, end);",
|
||||
" updateLayout(height, viewportHeight);",
|
||||
" return visible;",
|
||||
"}",
|
||||
"```",
|
||||
"",
|
||||
"> A quoted conclusion with enough text to exercise wrapping and inline rendering in the real Markdown component.",
|
||||
"",
|
||||
"Final paragraph with a concise recommendation and a note about correctness tests.",
|
||||
].join("\n");
|
||||
|
||||
const header = new Container();
|
||||
header.addChild(new Text("pi benchmark\nheader line", 0, 0));
|
||||
const resources = new Container();
|
||||
resources.addChild(new Text("resource", 0, 0));
|
||||
const chat = new Container();
|
||||
let streamingMarkdown: Markdown | undefined;
|
||||
for (let index = 0; index < COMPONENT_COUNT; index++) {
|
||||
const markdown = new Markdown(index % 2 === 0 ? userMarkdown : assistantMarkdown, 1, 0, theme);
|
||||
chat.addChild(markdown);
|
||||
if (index === COMPONENT_COUNT - 1) streamingMarkdown = markdown;
|
||||
}
|
||||
if (!streamingMarkdown) throw new Error("Benchmark requires at least one assistant Markdown component");
|
||||
|
||||
const document = new Container();
|
||||
document.addChild(header);
|
||||
document.addChild(resources);
|
||||
document.addChild(chat);
|
||||
|
||||
const transcript = new ScrollView(document, { follow: "none", primary: true });
|
||||
const dock = new VStack([new Text("editor\nfooter", 0, 0)]);
|
||||
const root = new VStack([
|
||||
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", minSize: 1 },
|
||||
]);
|
||||
const terminal = new NullTerminal();
|
||||
const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench");
|
||||
tui.setLayoutRoot(root);
|
||||
tui.start();
|
||||
tui.renderNow();
|
||||
|
||||
const totalLines = document.render(terminal.columns).length;
|
||||
transcript.scrollTo(Math.floor(totalLines / 2));
|
||||
for (let frame = 0; frame < WARMUP_FRAMES; frame++) tui.renderNow();
|
||||
|
||||
function measure(name: string, frames: number, beforeFrame: (frame: number) => void): void {
|
||||
const start = performance.now();
|
||||
for (let frame = 0; frame < frames; frame++) {
|
||||
beforeFrame(frame);
|
||||
tui.renderNow();
|
||||
}
|
||||
const millisecondsPerFrame = (performance.now() - start) / frames;
|
||||
console.log(`${name}: ${millisecondsPerFrame.toFixed(3)} ms/frame (${frames} frames)`);
|
||||
}
|
||||
|
||||
console.log(`components=${COMPONENT_COUNT} lines=${totalLines} viewport=${terminal.columns}x${terminal.rows}`);
|
||||
measure("steady", 200, () => {});
|
||||
transcript.scrollTo(Math.floor(totalLines / 2));
|
||||
measure("scroll", 200, () => transcript.scrollBy(1));
|
||||
measure("streaming", 100, (frame) => {
|
||||
streamingMarkdown.setText(`${assistantMarkdown}\n\nstream ${"x".repeat(frame + 1)}`);
|
||||
});
|
||||
measure("resize", 10, (frame) => {
|
||||
terminal.columns = frame % 2 === 0 ? COLUMNS - 1 : COLUMNS;
|
||||
});
|
||||
|
||||
tui.stop();
|
||||
578
packages/tui/test/autocomplete.test.ts
Normal file
578
packages/tui/test/autocomplete.test.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, 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.ts";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const getSuggestions = (
|
||||
provider: CombinedAutocompleteProvider,
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
force: boolean = false,
|
||||
) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force });
|
||||
|
||||
describe("CombinedAutocompleteProvider", () => {
|
||||
describe("extractPathPrefix", () => {
|
||||
it("extracts / from 'hey /' when forced", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["hey /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 5; // After the "/"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
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", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/A"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 2; // After the "A"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
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", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/model"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 6; // After "model"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
||||
});
|
||||
|
||||
it("triggers for absolute paths after slash command argument", async () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/command /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 10; // After the second "/"
|
||||
|
||||
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
|
||||
|
||||
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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@";
|
||||
const result = await getSuggestions(provider, [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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"file.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@file.txt";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@file.txt"));
|
||||
});
|
||||
|
||||
test("filters are case insensitive", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@re";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value).sort();
|
||||
assert.deepStrictEqual(values, ["@README.md"]);
|
||||
});
|
||||
|
||||
test("ranks directories before files", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"src.txt": "text",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@src";
|
||||
const result = await getSuggestions(provider, [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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/index.ts": "export {};\n",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@index";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@src/index.ts"));
|
||||
});
|
||||
|
||||
test("matches deeply nested paths", async () => {
|
||||
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 = await getSuggestions(provider, [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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/components/Button.tsx": "export {};",
|
||||
"src/utils/helpers.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@components/";
|
||||
const result = await getSuggestions(provider, [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", async () => {
|
||||
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 = await getSuggestions(provider, [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("ranks shallower same-score @ matches before deeper matches", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile", "scope/projects"],
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@scope/pro";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.strictEqual(values[0], "@scope/projects/");
|
||||
assert.ok(values.includes("@scope/aaa/venv/lib/python3.12/site-packages/pkg/core/profile/"));
|
||||
});
|
||||
|
||||
test("includes scoped direct children when recursive @ matches are flooded", async () => {
|
||||
const floodedDirs = Array.from(
|
||||
{ length: 250 },
|
||||
(_, index) =>
|
||||
`scope/a${String(index + 1).padStart(3, "0")}/venv/lib/python3.12/site-packages/pkg/core/profile`,
|
||||
);
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["scope/projects", ...floodedDirs],
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@scope/pro";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.strictEqual(values[0], "@scope/projects/");
|
||||
assert.ok(
|
||||
values.some((value) => value.includes("/profile/")),
|
||||
"Should keep deep fuzzy matches after direct children",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes paths with spaces for @ suggestions", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@my";
|
||||
const result = await getSuggestions(provider, [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", async () => {
|
||||
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 = await getSuggestions(provider, [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("follows symlinked directories for fuzzy @ search", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"dir/some_file.txt": "real",
|
||||
},
|
||||
});
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"some_file.txt": "symlinked",
|
||||
},
|
||||
});
|
||||
symlinkSync("../outside", join(baseDir, "symlinked_dir"));
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@some";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@dir/some_file.txt"));
|
||||
assert.ok(values.includes("@symlinked_dir/some_file.txt"));
|
||||
});
|
||||
|
||||
test("returns symlinked directories when matching their name", async () => {
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"nested/file.txt": "symlinked",
|
||||
},
|
||||
});
|
||||
symlinkSync("../outside", join(baseDir, "symlinked_dir"));
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@symlinked";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@symlinked_dir/"));
|
||||
});
|
||||
|
||||
test("returns symlinked files without requiring type l", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"original.txt": "content",
|
||||
},
|
||||
});
|
||||
const linkPath = join(baseDir, "link.txt");
|
||||
symlinkSync("original.txt", linkPath);
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
|
||||
const line = "@link";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@link.txt"));
|
||||
});
|
||||
|
||||
test("returns the same @ suggestions when the cwd path contains the query", async () => {
|
||||
const normalBaseDir = join(rootDir, "cwd-normal");
|
||||
const queryInPathBaseDir = join(rootDir, "cwd-plan-repro");
|
||||
mkdirSync(normalBaseDir, { recursive: true });
|
||||
mkdirSync(queryInPathBaseDir, { recursive: true });
|
||||
|
||||
const structure = {
|
||||
dirs: ["packages/coding-agent/examples/extensions/plan-mode"],
|
||||
files: {
|
||||
"packages/coding-agent/examples/extensions/plan-mode/README.md": "readme",
|
||||
"packages/tui/docs/plan.md": "plan",
|
||||
},
|
||||
};
|
||||
setupFolder(normalBaseDir, structure);
|
||||
setupFolder(queryInPathBaseDir, structure);
|
||||
|
||||
const query = "@plan";
|
||||
const normalProvider = new CombinedAutocompleteProvider([], normalBaseDir, requireFdPath());
|
||||
const queryInPathProvider = new CombinedAutocompleteProvider([], queryInPathBaseDir, requireFdPath());
|
||||
|
||||
const normalResult = await getSuggestions(normalProvider, [query], 0, query.length);
|
||||
const queryInPathResult = await getSuggestions(queryInPathProvider, [query], 0, query.length);
|
||||
|
||||
const normalize = (result: Awaited<ReturnType<typeof getSuggestions>>) =>
|
||||
(result?.items ?? []).map((item) => `${item.label} :: ${item.description ?? ""}`).sort();
|
||||
|
||||
assert.deepStrictEqual(normalize(queryInPathResult), normalize(normalResult));
|
||||
assert.ok(
|
||||
normalize(normalResult).includes("plan-mode/ :: packages/coding-agent/examples/extensions/plan-mode"),
|
||||
);
|
||||
assert.ok(normalize(normalResult).includes("plan.md :: packages/tui/docs/plan.md"));
|
||||
});
|
||||
|
||||
test("continues autocomplete inside quoted @ paths", async () => {
|
||||
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 = await getSuggestions(provider, [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", async () => {
|
||||
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 = await getSuggestions(provider, [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("dot-slash path completion", () => {
|
||||
let baseDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("preserves ./ prefix when completing paths", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"update.sh": "#!/bin/bash",
|
||||
"utils.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "./up";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for ./ path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`);
|
||||
});
|
||||
|
||||
test("preserves ./ prefix for directory completions", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"src/index.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "./sr";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for ./ directory path");
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`);
|
||||
});
|
||||
});
|
||||
|
||||
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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "my";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
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", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
"my folder/other.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = '"my folder/"';
|
||||
const result = await getSuggestions(provider, [line], 0, line.length - 1, true);
|
||||
|
||||
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", async () => {
|
||||
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 = await getSuggestions(provider, [line], 0, cursorCol, true);
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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.ts");
|
||||
|
||||
// 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.ts");
|
||||
|
||||
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.ts");
|
||||
|
||||
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.ts");
|
||||
|
||||
// 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.ts");
|
||||
|
||||
// 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.ts");
|
||||
|
||||
// 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.ts");
|
||||
|
||||
/**
|
||||
* 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.ts");
|
||||
|
||||
/**
|
||||
* 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.ts");
|
||||
|
||||
// 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.ts");
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
130
packages/tui/test/chat-simple.ts
Normal file
130
packages/tui/test/chat-simple.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Simple chat interface demo using tui.ts
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.ts";
|
||||
import { Editor } from "../src/components/editor.ts";
|
||||
import { Loader } from "../src/components/loader.ts";
|
||||
import { Markdown } from "../src/components/markdown.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
// Create TUI
|
||||
const tui: TUI = new TuiMainScreen(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();
|
||||
43
packages/tui/test/editor-history-keybindings.test.ts
Normal file
43
packages/tui/test/editor-history-keybindings.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { Editor } from "../src/components/editor.ts";
|
||||
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { defaultEditorTheme } from "./test-themes.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
|
||||
});
|
||||
|
||||
describe("Editor prompt history keybindings", () => {
|
||||
it("browses history directly without first moving the cursor", () => {
|
||||
setKeybindings(
|
||||
new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.editor.historyPrevious": "ctrl+p",
|
||||
"tui.editor.historyNext": "ctrl+n",
|
||||
}),
|
||||
);
|
||||
const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme);
|
||||
editor.addToHistory("older prompt");
|
||||
editor.addToHistory("newer\nmultiline prompt");
|
||||
editor.setText("draft");
|
||||
editor.handleInput("\x1b[D");
|
||||
editor.handleInput("\x1b[D");
|
||||
|
||||
editor.handleInput("\x10"); // Ctrl+P
|
||||
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
|
||||
|
||||
editor.handleInput("\x10"); // Ctrl+P
|
||||
assert.strictEqual(editor.getText(), "older prompt");
|
||||
|
||||
editor.handleInput("\x0e"); // Ctrl+N
|
||||
assert.strictEqual(editor.getText(), "newer\nmultiline prompt");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 16 });
|
||||
|
||||
editor.handleInput("\x0e"); // Ctrl+N
|
||||
assert.strictEqual(editor.getText(), "draft");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 });
|
||||
});
|
||||
});
|
||||
4165
packages/tui/test/editor.test.ts
Normal file
4165
packages/tui/test/editor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
112
packages/tui/test/fuzzy.test.ts
Normal file
112
packages/tui/test/fuzzy.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.ts";
|
||||
|
||||
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("prioritizes exact matches over longer prefix matches", () => {
|
||||
const items = ["clone", "cl"];
|
||||
const result = fuzzyFilter(items, "cl", (x: string) => x);
|
||||
|
||||
assert.deepStrictEqual(result, ["cl", "clone"]);
|
||||
});
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
it("matches slash-separated provider/model queries against reordered text", () => {
|
||||
const item = { id: "gpt-5.5", provider: "openai-codex" };
|
||||
const result = fuzzyFilter([item], "openai-codex/gpt-5.5", (model) => `${model.id} ${model.provider}`);
|
||||
|
||||
assert.deepStrictEqual(result, [item]);
|
||||
});
|
||||
});
|
||||
57
packages/tui/test/image-test.ts
Normal file
57
packages/tui/test/image-test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import { Spacer } from "../src/components/spacer.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import { getCapabilities, getImageDimensions } from "../src/terminal-image.ts";
|
||||
import type { TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
|
||||
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: TUI = new TuiMainScreen(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();
|
||||
664
packages/tui/test/input.test.ts
Normal file
664
packages/tui/test/input.test.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
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("render", () => {
|
||||
it("supports a custom prompt and styled placeholder", () => {
|
||||
const input = new Input({
|
||||
prompt: "",
|
||||
placeholder: "Find transcript",
|
||||
placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`,
|
||||
});
|
||||
input.focused = true;
|
||||
|
||||
const [empty] = input.render(20);
|
||||
assert.ok(empty?.includes("\x1b[2m"));
|
||||
assert.strictEqual(stripTerminalSequences(empty ?? "").trimEnd(), "Find transcript");
|
||||
|
||||
input.handleInput("n");
|
||||
const [populated] = input.render(20);
|
||||
assert.strictEqual(stripTerminalSequences(populated ?? "").trimEnd(), "n");
|
||||
});
|
||||
|
||||
it("does not overflow with wide CJK and fullwidth text", () => {
|
||||
const width = 93;
|
||||
const cases = [
|
||||
"가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다",
|
||||
"これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお",
|
||||
"这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm",
|
||||
];
|
||||
const cursorPositions = [
|
||||
{ label: "start", move: (_input: Input) => {} },
|
||||
{
|
||||
label: "middle",
|
||||
move: (input: Input) => {
|
||||
for (let i = 0; i < 10; i++) input.handleInput("\x1b[C");
|
||||
},
|
||||
},
|
||||
{ label: "end", move: (input: Input) => input.handleInput("\x05") },
|
||||
];
|
||||
|
||||
for (const text of cases) {
|
||||
for (const { label, move } of cursorPositions) {
|
||||
const input = new Input();
|
||||
input.setValue(text);
|
||||
input.focused = true;
|
||||
move(input);
|
||||
|
||||
const [line] = input.render(width);
|
||||
assert.ok(line);
|
||||
assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text} at ${label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the cursor visible when horizontally scrolling wide text", () => {
|
||||
const input = new Input();
|
||||
const width = 20;
|
||||
const text = "가나다라마바사아자차카타파하";
|
||||
input.setValue(text);
|
||||
input.focused = true;
|
||||
input.handleInput("\x01");
|
||||
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
|
||||
|
||||
const [line] = input.render(width);
|
||||
assert.ok(line);
|
||||
assert.ok(visibleWidth(line) <= width);
|
||||
});
|
||||
});
|
||||
|
||||
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+W preserves ASCII punctuation boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo.bar");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), "foo.");
|
||||
|
||||
input.setValue("foo:bar");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), "foo:");
|
||||
});
|
||||
|
||||
it("Ctrl+W handles Unicode word boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
// "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界
|
||||
input.setValue("你好世界。你好,世界");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "你好世界。你好,");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes ","
|
||||
assert.strictEqual(input.getValue(), "你好世界。你好");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "你好世界。");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "。"
|
||||
assert.strictEqual(input.getValue(), "你好世界");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "你好");
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
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("Alt+D preserves ASCII punctuation boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo.bar baz");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "foo"
|
||||
assert.strictEqual(input.getValue(), ".bar baz");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "."
|
||||
assert.strictEqual(input.getValue(), "bar baz");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "bar"
|
||||
assert.strictEqual(input.getValue(), " baz");
|
||||
});
|
||||
|
||||
it("Alt+D handles Unicode word boundaries", () => {
|
||||
const input = new Input();
|
||||
|
||||
// "你好世界。你好,世界" segments as: 你好|世界|。|你好|,|世界
|
||||
input.setValue("你好世界。你好,世界");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), "世界。你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "。你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "。"
|
||||
assert.strictEqual(input.getValue(), "你好,世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "你好"
|
||||
assert.strictEqual(input.getValue(), ",世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes ","
|
||||
assert.strictEqual(input.getValue(), "世界");
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "世界"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
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(), "");
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/tui/test/key-tester.ts
Executable file
124
packages/tui/test/key-tester.ts
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
import { matchesKey } from "../src/keys.ts";
|
||||
import { ProcessTerminal } from "../src/terminal.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { truncateToWidth } from "../src/utils.ts";
|
||||
|
||||
/**
|
||||
* Simple key code logger component
|
||||
*/
|
||||
class KeyLogger implements Component {
|
||||
private log: string[] = [];
|
||||
private maxLines = 20;
|
||||
private tui: TUI;
|
||||
private terminal: ProcessTerminal;
|
||||
|
||||
constructor(tui: TUI, terminal: ProcessTerminal) {
|
||||
this.tui = tui;
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private protocolName(): string {
|
||||
if (this.terminal.kittyProtocolActive) return "kitty";
|
||||
if (this.terminal.modifyOtherKeysActive) return "modifyOtherKeys";
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
private fit(line: string, width: number): string {
|
||||
return truncateToWidth(line, width).padEnd(width);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push("=".repeat(width));
|
||||
lines.push(this.fit("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)", width));
|
||||
lines.push(this.fit(`Protocol: ${this.protocolName()}`, width));
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("");
|
||||
|
||||
// Log entries
|
||||
for (const entry of this.log) {
|
||||
lines.push(this.fit(entry, 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(this.fit("Test these:", width));
|
||||
lines.push(this.fit(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)", width));
|
||||
lines.push(this.fit(" - Alt/Option + Enter", width));
|
||||
lines.push(this.fit(" - Option/Alt + Backspace", width));
|
||||
lines.push(this.fit(" - Cmd/Ctrl + Backspace", width));
|
||||
lines.push(this.fit(" - Regular Backspace", width));
|
||||
lines.push("=".repeat(width));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up TUI
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const logger = new KeyLogger(tui, terminal);
|
||||
|
||||
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();
|
||||
|
||||
// Protocol negotiation completes asynchronously after the first render.
|
||||
// Refresh briefly/continuously so the displayed protocol state is not stale.
|
||||
setInterval(() => tui.requestRender(), 100);
|
||||
81
packages/tui/test/keybindings.test.ts
Normal file
81
packages/tui/test/keybindings.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.ts";
|
||||
|
||||
describe("KeybindingsManager", () => {
|
||||
it("binds Ctrl+J as a default newline alias", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.input.newLine"), ["shift+enter", "ctrl+j"]);
|
||||
assert.strictEqual(keybindings.matches("\n", "tui.input.newLine"), true);
|
||||
assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true);
|
||||
});
|
||||
|
||||
it("binds modified and unmodified editor viewport navigation", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+home", "ctrl+a"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+end", "ctrl+e"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageUp"), ["pageUp", "ctrl+pageUp"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]);
|
||||
});
|
||||
|
||||
it("leaves dedicated prompt history navigation unbound by default", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyPrevious"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyNext"), []);
|
||||
});
|
||||
|
||||
it("binds unmodified terminal viewport shortcuts to alternate-screen navigation", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up", "ctrl+up"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down", "ctrl+down"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]);
|
||||
});
|
||||
|
||||
it("does not evict selector confirm when input submit is rebound", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.input.submit": ["enter", "ctrl+enter"],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.input.submit"), ["enter", "ctrl+enter"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.select.confirm"), ["enter"]);
|
||||
});
|
||||
|
||||
it("does not evict cursor bindings when another action reuses the same key", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.select.up": ["up", "ctrl+p"],
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.select.up"), ["up", "ctrl+p"]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorUp"), ["up"]);
|
||||
});
|
||||
|
||||
it("still reports direct user binding conflicts without evicting defaults", () => {
|
||||
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
|
||||
"tui.input.submit": "ctrl+x",
|
||||
"tui.select.confirm": "ctrl+x",
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(keybindings.getConflicts(), [
|
||||
{
|
||||
key: "ctrl+x",
|
||||
keybindings: ["tui.input.submit", "tui.select.confirm"],
|
||||
},
|
||||
]);
|
||||
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLeft"), ["left", "ctrl+b"]);
|
||||
});
|
||||
});
|
||||
633
packages/tui/test/keys.test.ts
Normal file
633
packages/tui/test/keys.test.ts
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Tests for keyboard input handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
decodeKittyPrintable,
|
||||
decodePrintableKey,
|
||||
Key,
|
||||
matchesKey,
|
||||
parseKey,
|
||||
setKittyProtocolActive,
|
||||
} from "../src/keys.ts";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, fn: () => void): void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function withEnvVars(vars: Record<string, string | undefined>, fn: () => void): void {
|
||||
const entries = Object.entries(vars);
|
||||
const run = (index: number): void => {
|
||||
if (index >= entries.length) {
|
||||
fn();
|
||||
return;
|
||||
}
|
||||
const [name, value] = entries[index]!;
|
||||
withEnv(name, value, () => run(index + 1));
|
||||
};
|
||||
run(0);
|
||||
}
|
||||
|
||||
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 match super-modified Kitty bindings, including combined modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false);
|
||||
assert.strictEqual(parseKey("\x1b[107;9u"), "super+k");
|
||||
assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter");
|
||||
assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k");
|
||||
assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match digit bindings via Kitty CSI-u", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[49u", "1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false);
|
||||
assert.strictEqual(parseKey("\x1b[49u"), "1");
|
||||
assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[57400u", "1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57410u", "/"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57417u", "left"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true);
|
||||
assert.strictEqual(parseKey("\x1b[57399u"), "0");
|
||||
assert.strictEqual(parseKey("\x1b[57409u"), ".");
|
||||
assert.strictEqual(parseKey("\x1b[57413u"), "+");
|
||||
assert.strictEqual(parseKey("\x1b[57416u"), ",");
|
||||
assert.strictEqual(parseKey("\x1b[57417u"), "left");
|
||||
assert.strictEqual(parseKey("\x1b[57418u"), "right");
|
||||
assert.strictEqual(parseKey("\x1b[57419u"), "up");
|
||||
assert.strictEqual(parseKey("\x1b[57420u"), "down");
|
||||
assert.strictEqual(parseKey("\x1b[57421u"), "pageUp");
|
||||
assert.strictEqual(parseKey("\x1b[57422u"), "pageDown");
|
||||
assert.strictEqual(parseKey("\x1b[57423u"), "home");
|
||||
assert.strictEqual(parseKey("\x1b[57424u"), "end");
|
||||
assert.strictEqual(parseKey("\x1b[57425u"), "insert");
|
||||
assert.strictEqual(parseKey("\x1b[57426u"), "delete");
|
||||
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("modifyOtherKeys matching", () => {
|
||||
it("should match xterm modifyOtherKeys Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+d", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Ctrl+z", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Enter variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Tab variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;9~", "shift+tab"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;9~", "ctrl+tab"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;9~", "alt+tab"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;2;9~"), "shift+tab");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;9~"), "ctrl+tab");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Backspace variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace");
|
||||
assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Escape", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys Space variants", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;1;32~"), "space");
|
||||
assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys symbol combos", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys digit combos", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1");
|
||||
assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1");
|
||||
});
|
||||
|
||||
it("should match xterm modifyOtherKeys shifted uppercase letters", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;2;69~", "shift+e"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[27;6;69~", "ctrl+shift+e"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;2;69~"), "shift+e");
|
||||
assert.strictEqual(parseKey("\x1b[27;6;69~"), "shift+ctrl+e");
|
||||
});
|
||||
|
||||
it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true);
|
||||
assert.strictEqual(parseKey("\x1b[104;7u"), "ctrl+alt+h");
|
||||
});
|
||||
|
||||
it("should match Ctrl+Alt+letter via xterm modifyOtherKeys", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b[27;7;104~", "ctrl+alt+h"), true);
|
||||
assert.strictEqual(parseKey("\x1b[27;7;104~"), "ctrl+alt+h");
|
||||
});
|
||||
});
|
||||
|
||||
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 treat raw 0x08 as plain backspace outside Windows Terminal", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnv("WT_SESSION", undefined, () => {
|
||||
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
|
||||
assert.strictEqual(parseKey("\x7f"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
|
||||
assert.strictEqual(parseKey("\x08"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should treat raw 0x08 as ctrl+backspace in local Windows Terminal", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnvVars(
|
||||
{
|
||||
WT_SESSION: "test-session",
|
||||
SSH_CONNECTION: undefined,
|
||||
SSH_CLIENT: undefined,
|
||||
SSH_TTY: undefined,
|
||||
},
|
||||
() => {
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), false);
|
||||
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should treat raw 0x08 as plain backspace in Windows Terminal over SSH", () => {
|
||||
setKittyProtocolActive(false);
|
||||
withEnvVars(
|
||||
{
|
||||
WT_SESSION: "test-session",
|
||||
SSH_CONNECTION: "1 2 3 4",
|
||||
SSH_CLIENT: "1 2 3",
|
||||
SSH_TTY: "/dev/pts/1",
|
||||
},
|
||||
() => {
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
|
||||
assert.strictEqual(matchesKey("\x08", "backspace"), true);
|
||||
assert.strictEqual(parseKey("\x08"), "backspace");
|
||||
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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("\x1b1", "alt+1"), true);
|
||||
assert.strictEqual(parseKey("\x1b1"), "alt+1");
|
||||
assert.strictEqual(matchesKey("\x1b,", "alt+,"), true);
|
||||
assert.strictEqual(parseKey("\x1b,"), "alt+,");
|
||||
assert.strictEqual(matchesKey("\x1b.", "alt+."), true);
|
||||
assert.strictEqual(parseKey("\x1b."), "alt+.");
|
||||
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("\x1b1", "alt+1"), false);
|
||||
assert.strictEqual(parseKey("\x1b1"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b,", "alt+,"), false);
|
||||
assert.strictEqual(parseKey("\x1b,"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b.", "alt+."), false);
|
||||
assert.strictEqual(parseKey("\x1b."), 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 xterm Ctrl-modified viewport navigation", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[1;5H", "ctrl+home"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[1;5F", "ctrl+end"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[5;5~", "ctrl+pageUp"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[6;5~", "ctrl+pageDown"), true);
|
||||
assert.strictEqual(parseKey("\x1b[1;5H"), "ctrl+home");
|
||||
assert.strictEqual(parseKey("\x1b[1;5F"), "ctrl+end");
|
||||
assert.strictEqual(parseKey("\x1b[5;5~"), "ctrl+pageUp");
|
||||
assert.strictEqual(parseKey("\x1b[6;5~"), "ctrl+pageDown");
|
||||
});
|
||||
|
||||
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("decodeKittyPrintable", () => {
|
||||
it("should decode Kitty keypad functional keys to printable characters", () => {
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), ".");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "=");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ",");
|
||||
assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodePrintableKey", () => {
|
||||
it("should decode printable xterm modifyOtherKeys sequences", () => {
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;69~"), "E");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;196~"), "Ä");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;32~"), " ");
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;2;13~"), undefined);
|
||||
assert.strictEqual(decodePrintableKey("\x1b[27;6;69~"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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 parse shifted uppercase CSI-u letters as shift+letter", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b[69;2u", "shift+e"), true);
|
||||
assert.strictEqual(parseKey("\x1b[69;2u"), "shift+e");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(parseKey("\x1b[99;17u"), 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");
|
||||
assert.strictEqual(parseKey("1"), "1");
|
||||
assert.strictEqual(matchesKey("1", "1"), true);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
505
packages/tui/test/latex.test.ts
Normal file
505
packages/tui/test/latex.test.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { renderLatex } from "../src/index.ts";
|
||||
|
||||
type LatexCase = readonly [source: string, expected: string];
|
||||
|
||||
function defineCases(cases: readonly LatexCase[]): void {
|
||||
for (const [source, expected] of cases) {
|
||||
it(`renders ${JSON.stringify(source)}`, () => {
|
||||
assert.strictEqual(renderLatex(source), expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("renderLatex", () => {
|
||||
describe("Jacobian conjecture session using dollar delimiters", () => {
|
||||
defineCases([
|
||||
[String.raw`\mathbb{C}^3 \to \mathbb{C}^3`, "ℂ³ → ℂ³"],
|
||||
[
|
||||
String.raw`\{3x+2y,\; 27x^2-4z-1,\; x(x-1)(x+1)\} \quad\Rightarrow\quad x \in \{0, \pm 1\},`,
|
||||
"{3x+2y, 27x²-4z-1, x(x-1)(x+1)} ⇒ x ∈ {0, ± 1},",
|
||||
],
|
||||
[String.raw`F_1 = -\frac{1}{4x^2}.`, "F₁ = -1/(4x²)."],
|
||||
["-2", "-2"],
|
||||
["(0,0,-1/4)", "(0,0,-1/4)"],
|
||||
["(1,-3/2,13/2)", "(1,-3/2,13/2)"],
|
||||
["(1,1,1)", "(1,1,1)"],
|
||||
["(2,1,0)", "(2,1,0)"],
|
||||
["(-1/4, 0, 0)", "(-1/4, 0, 0)"],
|
||||
[String.raw`\{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)\}`, "{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)}"],
|
||||
["(2,1,1)", "(2,1,1)"],
|
||||
["(7/3,-2/5,11/7)", "(7/3,-2/5,11/7)"],
|
||||
[String.raw`\{y - p(x),\; q(x)\}`, "{y - p(x), q(x)}"],
|
||||
[String.raw`\deg q = 3`, "deg q = 3"],
|
||||
[String.raw`[\mathbb{C}(x,y,z):\mathbb{C}(F_1,F_2,F_3)] = 3`, "[ℂ(x,y,z):ℂ(F₁,F₂,F₃)] = 3"],
|
||||
["u = 1+xy", "u = 1+xy"],
|
||||
["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"],
|
||||
["F_1 = uG", "F₁ = uG"],
|
||||
["F_2 = y + 3xG", "F₂ = y + 3xG"],
|
||||
["x=0", "x = 0"],
|
||||
["F_2 = F_3 = 0", "F₂ = F₃ = 0"],
|
||||
["xy = -3/2", "xy = -3/2"],
|
||||
["x^2 z = 13/2", "x² z = 13/2"],
|
||||
[String.raw`\mathbb{C}^*`, "ℂ^*"],
|
||||
[String.raw`s \mapsto (s,\, -\tfrac{3}{2s},\, \tfrac{13}{2s^2})`, "s ↦ (s, -3/(2s), 13/(2s²))"],
|
||||
["X", "X"],
|
||||
[String.raw`p_\pm`, "p_±"],
|
||||
["F(-x,-y,z) = (F_1, -F_2, -F_3)", "F(-x,-y,z) = (F₁, -F₂, -F₃)"],
|
||||
["p_0", "p₀"],
|
||||
[String.raw`s \to \infty`, "s → ∞"],
|
||||
["(0,0,0)", "(0,0,0)"],
|
||||
[String.raw`\Rightarrow`, "⇒"],
|
||||
[String.raw`\ge 2`, "≥ 2"],
|
||||
[String.raw`\ge 3`, "≥ 3"],
|
||||
["1", "1"],
|
||||
[String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"],
|
||||
["4+3xy", "4+3xy"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("satellite calculation session using bracket delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} = 0.001\ \text{W/m}^2`,
|
||||
"E ≈ (0.1 lux)/(100 lm/W) = 0.001 W/m²",
|
||||
],
|
||||
[String.raw`\boxed{1\ \text{milliwatt per square metre}}`, "[1 milliwatt per square metre]"],
|
||||
[String.raw`5\ \text{km}^2 = 5{,}000{,}000\ \text{m}^2`, "5 km² = 5,000,000 m²"],
|
||||
[
|
||||
String.raw`P_{\text{light}} = 0.001 \times 5{,}000{,}000
|
||||
= \boxed{5{,}000\ \text{W}}`,
|
||||
"P_light = 0.001 × 5,000,000 = [5,000 W]",
|
||||
],
|
||||
[
|
||||
String.raw`P_{\text{electric}} = 5\ \text{kW} \times 0.2
|
||||
= \boxed{1\ \text{kW}}`,
|
||||
"P_electric = 5 kW × 0.2 = [1 kW]",
|
||||
],
|
||||
[String.raw`\pi(2.5\ \text{km})^2 = 19.6\ \text{km}^2`, "π(2.5 km)² = 19.6 km²"],
|
||||
[
|
||||
String.raw`0.001\ \text{W/m}^2 \times 19.6 \times 10^6\ \text{m}^2
|
||||
\approx \boxed{20\ \text{kW optical}}`,
|
||||
"0.001 W/m² × 19.6 × 10⁶ m² ≈ [20 kW optical]",
|
||||
],
|
||||
[
|
||||
String.raw`1\ \text{kW} \times \frac{1}{3600}\ \text{hour}
|
||||
= \boxed{0.28\ \text{Wh}}`,
|
||||
"1 kW × 1/3600 hour = [0.28 Wh]",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Jacobian conjecture sessions using parenthesis and bracket delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`,
|
||||
"det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\
|
||||
F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\
|
||||
F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0).
|
||||
\end{aligned}`,
|
||||
"F(0,0,-1/4) = (-1/4,0,0),\nF(1,-3/2,13/2) = (-1/4,0,0),\nF(-1,3/2,13/2) = (-1/4,0,0).",
|
||||
],
|
||||
["F=(F_1,F_2,F_3)", "F = (F₁,F₂,F₃)"],
|
||||
["F", "F"],
|
||||
["3", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("Jacobian matrix session using dollar delimiters", () => {
|
||||
defineCases([
|
||||
[
|
||||
String.raw`J = \begin{pmatrix}
|
||||
\frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} & \frac{\partial f_1}{\partial z} \\
|
||||
\frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\
|
||||
\frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z}
|
||||
\end{pmatrix}`,
|
||||
"J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n ⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n ⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
f_1 &= (1+xy)^3 z + y^2(1+xy)(4+3xy) \\
|
||||
f_2 &= y + 3x(1+xy)^2 z + 3xy^2(4+3xy) \\
|
||||
f_3 &= 2x - 3x^2y - x^3z
|
||||
\end{aligned}`,
|
||||
"f₁ = (1+xy)³ z + y²(1+xy)(4+3xy)\nf₂ = y + 3x(1+xy)² z + 3xy²(4+3xy)\nf₃ = 2x - 3x²y - x³z",
|
||||
],
|
||||
["x, y, z", "x, y, z"],
|
||||
["(x, y, z)", "(x, y, z)"],
|
||||
[String.raw`(0,\; 0,\; -\tfrac14)`, "(0, 0, -1/4)"],
|
||||
[String.raw`(-\tfrac14,\; 0,\; 0)`, "(-1/4, 0, 0)"],
|
||||
[String.raw`(1,\; -\tfrac32,\; \tfrac{13}{2})`, "(1, -3/2, 13/2)"],
|
||||
[String.raw`(-1,\; \tfrac32,\; \tfrac{13}{2})`, "(-1, 3/2, 13/2)"],
|
||||
[String.raw`(-\frac14, 0, 0)`, "(-1/4, 0, 0)"],
|
||||
[String.raw`F: \mathbb{C}^3 \to \mathbb{C}^3`, "F: ℂ³ → ℂ³"],
|
||||
[
|
||||
String.raw`F(0,0,-\tfrac14) = F(1,-\tfrac32,\tfrac{13}{2}) = F(-1,\tfrac32,\tfrac{13}{2}) = (-\tfrac14, 0, 0)`,
|
||||
"F(0,0,-1/4) = F(1,-3/2,13/2) = F(-1,3/2,13/2) = (-1/4, 0, 0)",
|
||||
],
|
||||
[String.raw`\mathbb{C}^3`, "ℂ³"],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
f_1 &= \frac{f_1^{\text{ut}}(u,t)}{x^2}, \quad
|
||||
f_2 = \frac{f_2^{\text{ut}}(u,t)}{x}, \quad
|
||||
f_3 = x\,(2 - 3u - t)
|
||||
\end{aligned}`,
|
||||
"f₁ = (f₁ᵘᵗ(u,t))/(x²), f₂ = (f₂ᵘᵗ(u,t))/x, f₃ = x (2 - 3u - t)",
|
||||
],
|
||||
[String.raw`\det J_F`, "det J_F"],
|
||||
[String.raw`(-\tfrac14, 0, 0)`, "(-1/4, 0, 0)"],
|
||||
["u = xy", "u = xy"],
|
||||
["t = x^2z", "t = x²z"],
|
||||
[String.raw`x \neq 0`, "x ≠ 0"],
|
||||
[String.raw`f_1^{\text{ut}}, f_2^{\text{ut}}`, "f₁ᵘᵗ, f₂ᵘᵗ"],
|
||||
["u,t", "u,t"],
|
||||
["x", "x"],
|
||||
["x, x^2", "x, x²"],
|
||||
[String.raw`\mathbb{C}^n \to \mathbb{C}^n`, "ℂⁿ → ℂⁿ"],
|
||||
[String.raw`n \geq 2`, "n ≥ 2"],
|
||||
[String.raw`\mathbb{P}^3`, "ℙ³"],
|
||||
]);
|
||||
});
|
||||
|
||||
describe("extended formulas from a renderer stress-test session", () => {
|
||||
defineCases([
|
||||
[String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"],
|
||||
[
|
||||
String.raw`\boxed{
|
||||
\mathcal{Z}(\beta)
|
||||
=
|
||||
\int_{\mathcal M}
|
||||
\exp\!\left(
|
||||
-\beta\left[
|
||||
\frac12 g^{ij}(x)\,\partial_i\phi\,\partial_j\phi
|
||||
+V(\phi)
|
||||
\right]\right)
|
||||
\mathcal D\phi
|
||||
}`,
|
||||
"[Z(β) = ∫_M exp( -β[ 1/2 gⁱʲ(x) ∂ᵢϕ ∂ⱼϕ +V(ϕ) ]) Dϕ]",
|
||||
],
|
||||
[
|
||||
String.raw`\begin{aligned}
|
||||
\nabla_\mu T^{\mu\nu}
|
||||
&=
|
||||
\frac{1}{\sqrt{-g}}
|
||||
\partial_\mu\!\left(\sqrt{-g}\,T^{\mu\nu}\right)
|
||||
+\Gamma^\nu_{\mu\lambda}T^{\mu\lambda}
|
||||
=0, \\[4pt]
|
||||
R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu}
|
||||
&=
|
||||
\frac{8\pi G}{c^4}T_{\mu\nu}.
|
||||
\end{aligned}`,
|
||||
"∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).",
|
||||
],
|
||||
[
|
||||
String.raw`f(z)
|
||||
=
|
||||
\frac{1}{2\pi i}
|
||||
\oint_{\gamma}
|
||||
\frac{f(\zeta)}{\zeta-z}\,d\zeta,
|
||||
\qquad
|
||||
\det\!\begin{pmatrix}
|
||||
\lambda-a & -b & 0\\
|
||||
-c & \lambda-d & -e\\
|
||||
0 & -f & \lambda-g
|
||||
\end{pmatrix}
|
||||
=0.`,
|
||||
[
|
||||
"f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.",
|
||||
`${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`,
|
||||
`${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`,
|
||||
].join("\n"),
|
||||
],
|
||||
[
|
||||
String.raw`\Psi(x,t)=
|
||||
\sum_{n=1}^{\infty}
|
||||
\underbrace{
|
||||
c_n
|
||||
\sqrt{\frac{2}{L}}
|
||||
\sin\!\left(\frac{n\pi x}{L}\right)
|
||||
}_{\text{spatial eigenmode}}
|
||||
\exp\!\left(-\frac{i\hbar n^2\pi^2}{2mL^2}t\right),
|
||||
\qquad
|
||||
|\Psi(x,t)|^2
|
||||
=
|
||||
\begin{cases}
|
||||
\Psi^\ast\Psi, & 0<x<L,\\
|
||||
0, & \text{otherwise}.
|
||||
\end{cases}`,
|
||||
"Ψ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎧ Ψ^∗Ψ if 0 < x < L,\n⎩ 0 otherwise.",
|
||||
],
|
||||
[String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "x = (-b±√(b²-4ac))/(2a)"],
|
||||
[String.raw`\int_0^\infty e^{-x^2}\,dx=\frac{\sqrt{\pi}}{2}`, "∫₀^∞ e^(-x²) dx = (√π)/2"],
|
||||
[String.raw`e^{i\theta}=\cos\theta+i\sin\theta`, "e^(iθ) = cos θ+i sin θ"],
|
||||
[String.raw`\sum_{n=1}^{\infty}\frac{1}{n^2}=\frac{\pi^2}{6}`, "∑ₙ₌₁^∞1/(n²) = π²/6"],
|
||||
[String.raw`\lim_{x\to 0}\frac{\sin x}{x}=1`, "lim[x→0] (sin x)/x = 1"],
|
||||
[
|
||||
String.raw`\lim_{n\to\infty}
|
||||
\left(1+\frac{1}{n}\right)^n=e`,
|
||||
"lim[n→∞] (1+1/n)ⁿ = e",
|
||||
],
|
||||
[
|
||||
String.raw`\int_0^1 \frac{x^2}{1+x^3}\,dx
|
||||
=\frac{1}{3}\ln 2`,
|
||||
"∫₀¹ x²/(1+x³) dx = 1/3 ln 2",
|
||||
],
|
||||
[
|
||||
String.raw`\sum_{k=1}^{n}\frac{k}{k+1}
|
||||
=n+1-H_{n+1}`,
|
||||
"∑ₖ₌₁ⁿk/(k+1) = n+1-Hₙ₊₁",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{
|
||||
\displaystyle \frac{x^2+1}{x-1}
|
||||
-
|
||||
\displaystyle \frac{2x}{x+1}
|
||||
}{
|
||||
\displaystyle \frac{x}{x^2-1}
|
||||
}`,
|
||||
"((x²+1)/(x-1) - 2x/(x+1))/(x/(x²-1))",
|
||||
],
|
||||
[
|
||||
String.raw`\lim_{x\to 0}
|
||||
\frac{
|
||||
\displaystyle \frac{\sin x}{x}-1
|
||||
}{
|
||||
\displaystyle \frac{e^x-1}{x}-1
|
||||
}
|
||||
=0`,
|
||||
"lim[x→0] ((sin x)/x-1)/((eˣ-1)/x-1) = 0",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{
|
||||
1+\displaystyle\frac{1}{1+\frac{1}{x}}
|
||||
}{
|
||||
1-\displaystyle\frac{1}{1-\frac{1}{x}}
|
||||
}`,
|
||||
"(1+1/(1+1/x))/(1-1/(1-1/x))",
|
||||
],
|
||||
[
|
||||
String.raw`\sum_{n=1}^{\infty}
|
||||
\frac{
|
||||
\displaystyle \frac{1}{n}-\frac{1}{n+1}
|
||||
}{
|
||||
\displaystyle 1+\frac{1}{n^2}
|
||||
}`,
|
||||
"∑ₙ₌₁^∞ (1/n-1/(n+1))/(1+1/(n²))",
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders common symbols, roots, sums, and integrals", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx = \sqrt{\pi}`),
|
||||
"∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx = √π",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders common accents and binomial notation", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\binom{n}{k}+\vec{x}+\hat{y}+\overline{AB}`),
|
||||
"(n choose k)+x⃗+ŷ+overline(AB)",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders extended symbols and negated relations", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\epsilon+\varepsilon+\varsigma+\varkappa+\oplus+\otimes+\therefore+\because`),
|
||||
"ϵ+ε+ς+ϰ+⊕+⊗+∴+∵",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`A\not\subseteq B,\quad x\not\in X`), "A ⊈ B, x ∉ X");
|
||||
});
|
||||
|
||||
it("renders relational algebra join operators", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`R\bowtie S,\quad R\Join S`), "R ⋈ S, R ⋈ S");
|
||||
assert.strictEqual(renderLatex(String.raw`R\ltimes S,\quad R\rtimes S`), "R ⋉ S, R ⋊ S");
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`R\leftouterjoin S,\quad R\rightouterjoin S,\quad R\fullouterjoin S`),
|
||||
"R ⟕ S, R ⟖ S, R ⟗ S",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders delimiter commands and invisible delimiters", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`),
|
||||
"|x|+‖v‖+dy/(dx)|ₓ₌₀",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x > 0 }");
|
||||
});
|
||||
|
||||
it("renders named, modular, overlaid, and underlaid operators", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)");
|
||||
assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a ≡ b (mod n)");
|
||||
assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ");
|
||||
});
|
||||
|
||||
it("renders indexed roots and additional accents and wrappers", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sqrt[2]{x}+\sqrt[3]{x}+\sqrt[4]{x}+\sqrt[n]{x}+\sqrt[k]{x+1}`),
|
||||
"√x+∛x+∜x+ⁿ√x+ᵏ√(x+1)",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`),
|
||||
"x́+ỳ+widehat(xyz)+overrightarrow(AB)",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x");
|
||||
});
|
||||
|
||||
it("renders additional display environments", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`),
|
||||
"a = b\n= c",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`),
|
||||
"a = b c = d\ne = f g = h",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses natural case conditions and aligns matrix columns", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`),
|
||||
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`),
|
||||
"⎛ 1 │ 200 ⎞\n⎝ 3000 │ 4 ⎠",
|
||||
);
|
||||
});
|
||||
|
||||
it("composes matrices with fractions and adjacent matrices", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`R\left(\frac{\pi}{4}\right)
|
||||
=
|
||||
\begin{pmatrix}
|
||||
\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\
|
||||
\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2}
|
||||
\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
" π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`\mathbf w
|
||||
=
|
||||
R\left(\frac{\pi}{4}\right)
|
||||
\begin{pmatrix}1\\0\end{pmatrix}
|
||||
=
|
||||
\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
" π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(
|
||||
String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`,
|
||||
{ display: true },
|
||||
),
|
||||
"Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }),
|
||||
" n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes relation, multiplication, and named-operator spacing", () => {
|
||||
for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) {
|
||||
assert.strictEqual(renderLatex(source), "x = y");
|
||||
}
|
||||
assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀");
|
||||
assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0");
|
||||
assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B");
|
||||
assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π");
|
||||
assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x");
|
||||
assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ");
|
||||
assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)");
|
||||
});
|
||||
|
||||
it("treats a backslash followed by a line ending as control space", () => {
|
||||
const source = String.raw`\boxed{
|
||||
(1,1,1),\ (1,1,2),\ (1,2,5),\ (1,5,13),\ (2,5,29),\
|
||||
(1,13,34),\ (1,34,89)
|
||||
}.`;
|
||||
assert.strictEqual(
|
||||
renderLatex(source, { display: true }),
|
||||
"[(1,1,1), (1,1,2), (1,2,5), (1,5,13), (2,5,29), (1,13,34), (1,34,89)].",
|
||||
);
|
||||
assert.strictEqual(renderLatex("a\\\r\nb"), "a b");
|
||||
});
|
||||
|
||||
it("stacks operator limits in display mode", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0");
|
||||
assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X");
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`, {
|
||||
display: true,
|
||||
}),
|
||||
"arg max f(x)\n x∈X",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\int\nolimits_0^1 f(x)\,dx`, { display: true }), "∫₀¹ f(x) dx");
|
||||
assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0");
|
||||
});
|
||||
|
||||
it("uses the middle brace for intermediate case rows", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`),
|
||||
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0",
|
||||
);
|
||||
});
|
||||
|
||||
it("stacks fractions in display mode", () => {
|
||||
assert.strictEqual(
|
||||
renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, {
|
||||
display: true,
|
||||
}),
|
||||
" -b±√(b²-4ac)\nx = ────────────\n 2a",
|
||||
);
|
||||
assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1");
|
||||
assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2");
|
||||
});
|
||||
|
||||
it("keeps nested display fractions linear", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
String.raw`\frac{\frac{x^2+1}{x-1}-\frac{2x}{x+1}}{\frac{x}{x^2-1}}`,
|
||||
"(x²+1)/(x-1)-2x/(x+1)\n─────────────────────\n x/(x²-1)",
|
||||
],
|
||||
[
|
||||
String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`,
|
||||
" (sin x)/x-1\nlim ─────────── = 0\nx→0 (eˣ-1)/x-1",
|
||||
],
|
||||
[
|
||||
String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`,
|
||||
"1+1/(1+1/x)\n───────────\n1-1/(1-1/x)",
|
||||
],
|
||||
];
|
||||
for (const [source, expected] of cases) {
|
||||
assert.strictEqual(renderLatex(source, { display: true }), expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps fractions linear in scripts and text-style fractions", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)");
|
||||
assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2");
|
||||
});
|
||||
|
||||
it("returns undefined for unsupported commands", () => {
|
||||
assert.strictEqual(renderLatex(String.raw`x + \unknown{y}`), undefined);
|
||||
});
|
||||
|
||||
it("returns undefined for malformed groups and environments", () => {
|
||||
const malformed = [String.raw`\frac{1}{x`, "x}", String.raw`\begin{matrix}1 & 2`, "x\\"];
|
||||
for (const source of malformed) {
|
||||
assert.strictEqual(renderLatex(source), undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
367
packages/tui/test/layout.test.ts
Normal file
367
packages/tui/test/layout.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { HStack } from "../src/components/h-stack.ts";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import { renderLayoutFrame } from "../src/layout.ts";
|
||||
import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts";
|
||||
import { stripTerminalSequences } from "../src/utils.ts";
|
||||
|
||||
function visibleLines(lines: string[]): string[] {
|
||||
return lines.map((line) => stripTerminalSequences(line).trimEnd());
|
||||
}
|
||||
|
||||
describe("viewport layout", () => {
|
||||
it("allocates vertical grow space deterministically", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("top", 0, 0), basis: 1, shrink: 0 },
|
||||
{ component: new Text("body", 0, 0), basis: 0, grow: 1 },
|
||||
]),
|
||||
10,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
frame.root.children.map((child) => child.rect.height),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["top", "body", "", ""]);
|
||||
});
|
||||
|
||||
it("does not render fixed-basis scroll content during stack measurement", () => {
|
||||
let renderCount = 0;
|
||||
const transcript = new ScrollView({
|
||||
render: () => {
|
||||
renderCount += 1;
|
||||
return ["one", "two", "three"];
|
||||
},
|
||||
invalidate: () => {},
|
||||
});
|
||||
const root = new VStack([
|
||||
{ component: transcript, basis: 0, grow: 1 },
|
||||
{ component: new Text("dock", 0, 0), basis: "auto" },
|
||||
]);
|
||||
renderLayoutFrame(root, 10, 3, () => {});
|
||||
assert.strictEqual(renderCount, 1);
|
||||
});
|
||||
|
||||
it("paints only clipped rows from very large scroll content", () => {
|
||||
const lineCount = 1_000_000_000;
|
||||
const lines: string[] = [];
|
||||
lines.length = lineCount;
|
||||
lines[lineCount - 4] = "before";
|
||||
lines[lineCount - 3] = "visible 1";
|
||||
lines[lineCount - 2] = "visible 2";
|
||||
lines[lineCount - 1] = "visible 3";
|
||||
const transcript = new ScrollView(
|
||||
{
|
||||
render: () => lines,
|
||||
invalidate: () => {},
|
||||
},
|
||||
{ follow: "end" },
|
||||
);
|
||||
|
||||
const frame = renderLayoutFrame(transcript, 10, 3, () => {});
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["visible 1", "visible 2", "visible 3"]);
|
||||
});
|
||||
|
||||
it("shrinks entries to their minimum sizes", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("a1\na2\na3", 0, 0), shrink: 1, minSize: 1 },
|
||||
{ component: new Text("b1\nb2\nb3", 0, 0), shrink: 0 },
|
||||
]),
|
||||
10,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
frame.root.children.map((child) => child.rect.height),
|
||||
[1, 3],
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]);
|
||||
});
|
||||
|
||||
it("includes nested minimum sizes in intrinsic stack measurement", () => {
|
||||
const dock = new VStack([
|
||||
new Text("top1\ntop2\ntop3", 0, 0),
|
||||
{ component: new Text("selector", 0, 0), minSize: 3 },
|
||||
new Text("below", 0, 0),
|
||||
{ component: new Text("footer", 0, 0), minSize: 1 },
|
||||
]);
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([
|
||||
{ component: new Text("body", 0, 0), basis: 0, grow: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", minSize: 1 },
|
||||
]),
|
||||
10,
|
||||
9,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), [
|
||||
"body",
|
||||
"top1",
|
||||
"top2",
|
||||
"top3",
|
||||
"selector",
|
||||
"",
|
||||
"",
|
||||
"below",
|
||||
"footer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits gaps around invisible entries", () => {
|
||||
const stack = new VStack(
|
||||
[new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)],
|
||||
{ gap: 1 },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
stack.render(10).map((line) => line.trimEnd()),
|
||||
["one", "", "two"],
|
||||
);
|
||||
});
|
||||
|
||||
it("crops Kitty images at a scroll view's lower boundary", () => {
|
||||
const imageId = 124;
|
||||
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
|
||||
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
|
||||
const transcript = new ScrollView({
|
||||
render: () => ["one", "two", imageLine, "", ""],
|
||||
invalidate: () => {},
|
||||
});
|
||||
const frame = renderLayoutFrame(
|
||||
new VStack([{ component: transcript, basis: 0, grow: 1 }, new Text("dock", 0, 0)]),
|
||||
20,
|
||||
4,
|
||||
() => {},
|
||||
);
|
||||
|
||||
assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1"));
|
||||
});
|
||||
|
||||
it("composes horizontal children at allocated widths", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new HStack([
|
||||
{ component: new Text("left", 0, 0), basis: 6, shrink: 0 },
|
||||
{ component: new Text("right", 0, 0), basis: 6, shrink: 0 },
|
||||
]),
|
||||
12,
|
||||
1,
|
||||
() => {},
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["left right"]);
|
||||
});
|
||||
|
||||
it("does not paint zero-width horizontal children", () => {
|
||||
const frame = renderLayoutFrame(
|
||||
new HStack([
|
||||
{ component: new Text("hidden", 0, 0), basis: 0, shrink: 0 },
|
||||
{ component: new Text("shown", 0, 0), basis: 0, grow: 1 },
|
||||
]),
|
||||
5,
|
||||
1,
|
||||
() => {},
|
||||
);
|
||||
assert.deepStrictEqual(visibleLines(frame.lines), ["shown"]);
|
||||
});
|
||||
|
||||
it("tracks follow-end state and returns unused scroll delta", () => {
|
||||
const scrollView = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0), {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
});
|
||||
renderLayoutFrame(scrollView, 10, 3, () => {});
|
||||
assert.strictEqual(scrollView.scrollTop, 3);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, true);
|
||||
|
||||
assert.strictEqual(scrollView.scrollBy(-2), 0);
|
||||
assert.strictEqual(scrollView.scrollTop, 1);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, false);
|
||||
assert.strictEqual(scrollView.scrollBy(-3), -2);
|
||||
assert.strictEqual(scrollView.scrollTop, 0);
|
||||
assert.strictEqual(scrollView.scrollBy(10), 7);
|
||||
assert.strictEqual(scrollView.scrollTop, 3);
|
||||
assert.strictEqual(scrollView.isFollowingEnd, true);
|
||||
});
|
||||
|
||||
it("renders a proportional glyph scrollbar with an expanded active thumb", async () => {
|
||||
const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"];
|
||||
const contentBackground = "\x1b[42m";
|
||||
const trackColor = "\x1b[38;5;2m";
|
||||
const thumbColor = "\x1b[38;5;1m";
|
||||
const scrollbarTrackStyle = (text: string) => `${trackColor}${text}\x1b[39m`;
|
||||
const scrollbarThumbStyle = (text: string) => `${thumbColor}${text}\x1b[39m`;
|
||||
const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`);
|
||||
const scrollView = new ScrollView(content, {
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
scrollbarHideDelayMs: 10,
|
||||
});
|
||||
const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines;
|
||||
const visible = (lines: string[]) => lines.map(stripTerminalSequences);
|
||||
|
||||
let lines = render();
|
||||
assert.deepStrictEqual(visible(lines), sourceLines.slice(0, 4));
|
||||
|
||||
scrollView.scrollBy(2);
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde┃", "abcde┃", "abcde│"]);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(trackColor)),
|
||||
[true, false, false, true],
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(thumbColor)),
|
||||
[false, true, true, false],
|
||||
);
|
||||
|
||||
scrollView.setScrollbarActive(true);
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde█", "abcde█", "abcde│"]);
|
||||
assert.deepStrictEqual(
|
||||
lines.map((line) => line.includes(thumbColor)),
|
||||
[false, true, true, false],
|
||||
);
|
||||
assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(thumbColor));
|
||||
|
||||
scrollView.setScrollbarActive(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), sourceLines.slice(2, 6));
|
||||
|
||||
scrollView.scrollToEnd();
|
||||
lines = render();
|
||||
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde│", "abcde┃", "abcde┃"]);
|
||||
|
||||
scrollView.scrollToStart();
|
||||
lines = render();
|
||||
assert.strictEqual(visible(lines)[0], "abcd ┃");
|
||||
|
||||
const followedContent = new Text(sourceLines.join("\n"), 0, 0);
|
||||
const followed = new ScrollView(followedContent, {
|
||||
follow: "end",
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
renderLayoutFrame(followed, 6, 4, () => {});
|
||||
assert.strictEqual(followed.scrollTop, 4);
|
||||
followedContent.setText(`${sourceLines.join("\n")}\nabcde9`);
|
||||
const growthFrame = renderLayoutFrame(followed, 6, 4, () => {});
|
||||
assert.strictEqual(followed.scrollTop, 5);
|
||||
assert.ok(growthFrame.lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))));
|
||||
|
||||
const fittingContent = new Text("1\n2", 0, 0);
|
||||
const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarThumbStyle });
|
||||
renderLayoutFrame(automatic, 6, 4, () => {});
|
||||
automatic.scrollBy(1);
|
||||
assert.ok(
|
||||
renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))),
|
||||
);
|
||||
|
||||
const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarThumbStyle });
|
||||
const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {});
|
||||
assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5);
|
||||
assert.ok(visible(alwaysFittingFrame.lines).every((line) => line.endsWith("┃")));
|
||||
|
||||
const alwaysOverflowing = new ScrollView(content, {
|
||||
scrollbar: "always",
|
||||
scrollbarTrackStyle,
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {});
|
||||
assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5);
|
||||
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("┃")).length, 2);
|
||||
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("│")).length, 2);
|
||||
for (const line of alwaysOverflowingFrame.lines) {
|
||||
const scrollbarStyleIndex = Math.max(line.lastIndexOf(trackColor), line.lastIndexOf(thumbColor));
|
||||
const reservedColumnResetIndex = line.lastIndexOf("\x1b[0m\x1b]8;;\x07", scrollbarStyleIndex);
|
||||
assert.ok(reservedColumnResetIndex > line.lastIndexOf(contentBackground));
|
||||
}
|
||||
|
||||
const thumbHeightFor = (contentHeight: number) => {
|
||||
const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), {
|
||||
scrollbar: "auto",
|
||||
scrollbarThumbStyle,
|
||||
});
|
||||
renderLayoutFrame(sized, 6, 20, () => {});
|
||||
sized.scrollBy(1);
|
||||
return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) =>
|
||||
stripTerminalSequences(line).endsWith("┃"),
|
||||
).length;
|
||||
};
|
||||
assert.strictEqual(thumbHeightFor(21), 19);
|
||||
assert.strictEqual(thumbHeightFor(40), 10);
|
||||
assert.strictEqual(thumbHeightFor(100), 4);
|
||||
assert.strictEqual(thumbHeightFor(400), 2);
|
||||
});
|
||||
|
||||
it("preserves only the underlying background beneath overlay scrollbar glyphs", () => {
|
||||
const background = "\x1b[42m";
|
||||
const borderForeground = "\x1b[31m";
|
||||
const content = {
|
||||
render: (width: number) =>
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
() => `${background}${"x".repeat(width - 1)}${borderForeground}│\x1b[39m\x1b[49m`,
|
||||
),
|
||||
invalidate: () => {},
|
||||
};
|
||||
const scrollView = new ScrollView(content, {
|
||||
scrollbar: "auto",
|
||||
scrollbarTrackStyle: (text) => text,
|
||||
scrollbarThumbStyle: (text) => text,
|
||||
});
|
||||
renderLayoutFrame(scrollView, 6, 4, () => {});
|
||||
scrollView.scrollBy(1);
|
||||
const frame = renderLayoutFrame(scrollView, 6, 4, () => {});
|
||||
|
||||
assert.deepStrictEqual(frame.lines.map(stripTerminalSequences), ["xxxxx│", "xxxxx┃", "xxxxx┃", "xxxxx│"]);
|
||||
for (const line of frame.lines) {
|
||||
assert.ok(line.includes(background));
|
||||
assert.ok(!line.includes(borderForeground));
|
||||
assert.ok(line.includes(`\x1b[0m\x1b]8;;\x07${background}`));
|
||||
}
|
||||
});
|
||||
|
||||
it("updates reserved scrollbar layout at runtime", () => {
|
||||
const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" });
|
||||
const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {});
|
||||
const always = render();
|
||||
assert.deepStrictEqual(visibleLines(always.lines), ["12345┃", "6 ┃"]);
|
||||
assert.strictEqual(always.root.children[0]?.rect.width, 6);
|
||||
assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5);
|
||||
|
||||
scrollView.setScrollbar("hidden");
|
||||
assert.strictEqual(render().root.children[0]?.children[0]?.rect.width, 6);
|
||||
assert.strictEqual(scrollView.isScrollbarVisible, false);
|
||||
});
|
||||
|
||||
it("measures nested scroll content from constrained child geometry", () => {
|
||||
const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0));
|
||||
const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)]));
|
||||
renderLayoutFrame(outer, 10, 2, () => {});
|
||||
|
||||
assert.strictEqual(inner.viewportHeight, 2);
|
||||
assert.strictEqual(outer.scrollBy(10), 9);
|
||||
assert.strictEqual(outer.scrollTop, 1);
|
||||
});
|
||||
|
||||
it("rebuilds geometry after content changes", () => {
|
||||
const text = new Text("one", 0, 0);
|
||||
const root = new VStack([text]);
|
||||
const first = renderLayoutFrame(root, 10, 4, () => {});
|
||||
text.setText("one\ntwo\nthree");
|
||||
const second = renderLayoutFrame(root, 10, 4, () => {});
|
||||
|
||||
assert.strictEqual(first.root.children[0]?.lines?.length, 1);
|
||||
assert.strictEqual(second.root.children[0]?.lines?.length, 3);
|
||||
});
|
||||
});
|
||||
1760
packages/tui/test/markdown.test.ts
Normal file
1760
packages/tui/test/markdown.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
249
packages/tui/test/mouse-components.test.ts
Normal file
249
packages/tui/test/mouse-components.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor, type EditorTheme } from "../src/components/editor.ts";
|
||||
import { Input } from "../src/components/input.ts";
|
||||
import { SelectList, type SelectListTheme } from "../src/components/select-list.ts";
|
||||
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
||||
import { Container, type TuiMouseEvent, type TuiMouseEventType } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
function mouse(type: TuiMouseEventType, x: number, y: number, width = 80, height = 10): TuiMouseEvent {
|
||||
return {
|
||||
type,
|
||||
button: "left",
|
||||
x,
|
||||
y,
|
||||
screenX: x,
|
||||
screenY: y,
|
||||
width,
|
||||
height,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
...(type === "click" ? { clickCount: 1 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const selectTheme: SelectListTheme = {
|
||||
selectedPrefix: (text) => text,
|
||||
selectedText: (text) => text,
|
||||
description: (text) => text,
|
||||
scrollInfo: (text) => text,
|
||||
noMatch: (text) => text,
|
||||
};
|
||||
|
||||
const settingsTheme: SettingsListTheme = {
|
||||
label: (text) => text,
|
||||
value: (text) => text,
|
||||
description: (text) => text,
|
||||
cursor: "> ",
|
||||
hint: (text) => text,
|
||||
};
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => text,
|
||||
selectList: selectTheme,
|
||||
};
|
||||
|
||||
class InputOverlay extends Container {
|
||||
readonly input = new Input();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.input);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
describe("mouse-aware components", () => {
|
||||
it("positions a single-line input cursor on press", () => {
|
||||
const input = new Input();
|
||||
input.setValue("hello");
|
||||
input.render(20);
|
||||
|
||||
assert.strictEqual(input.handleMouse(mouse("press", 4, 0, 20, 1))?.handled, true);
|
||||
input.handleInput("X");
|
||||
assert.strictEqual(input.getValue(), "heXllo");
|
||||
});
|
||||
|
||||
it("selects and activates list rows", () => {
|
||||
const list = new SelectList(
|
||||
[
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
{ value: "c", label: "C" },
|
||||
{ value: "d", label: "D" },
|
||||
{ value: "e", label: "E" },
|
||||
],
|
||||
3,
|
||||
selectTheme,
|
||||
);
|
||||
let selected: string | undefined;
|
||||
list.onSelect = (item) => {
|
||||
selected = item.value;
|
||||
};
|
||||
|
||||
assert.strictEqual(list.handleMouse(mouse("press", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "c");
|
||||
assert.strictEqual(list.handleMouse(mouse("click", 1, 2, 40, 3))?.handled, true);
|
||||
assert.strictEqual(selected, "c");
|
||||
});
|
||||
|
||||
it("activates settings rows", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
[
|
||||
{ id: "mode", label: "Mode", currentValue: "one", values: ["one", "two"] },
|
||||
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
|
||||
{ id: "third", label: "Third", currentValue: "low", values: ["low", "high"] },
|
||||
{ id: "fourth", label: "Fourth", currentValue: "x", values: ["x", "y"] },
|
||||
],
|
||||
3,
|
||||
settingsTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
);
|
||||
|
||||
list.handleMouse(mouse("press", 1, 2, 40, 5));
|
||||
list.handleMouse(mouse("click", 1, 2, 40, 5));
|
||||
assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]);
|
||||
});
|
||||
|
||||
for (const row of [0, 4]) {
|
||||
it(`ignores hover and clicks visible select-list row ${row} after scrolling`, () => {
|
||||
const list = new SelectList(
|
||||
Array.from({ length: 12 }, (_, i) => ({ value: `item-${i}`, label: `Item ${i}` })),
|
||||
5,
|
||||
selectTheme,
|
||||
);
|
||||
const changes: string[] = [];
|
||||
let selected: string | undefined;
|
||||
list.onSelectionChange = (item) => changes.push(item.value);
|
||||
list.onSelect = (item) => {
|
||||
selected = item.value;
|
||||
};
|
||||
list.setSelectedIndex(5);
|
||||
list.handleMouse({ ...mouse("wheel", 1, row), wheelDelta: 1 });
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
||||
assert.deepStrictEqual(changes, ["item-6"]);
|
||||
const before = list.render(80);
|
||||
assert.match(before[row], new RegExp(`Item ${4 + row}$`));
|
||||
|
||||
for (const y of [0, 1, 2, 3, 4, row]) {
|
||||
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y), button: "none" }), undefined);
|
||||
assert.deepStrictEqual(list.render(80), before);
|
||||
}
|
||||
assert.strictEqual(list.getSelectedItem()?.value, "item-6");
|
||||
assert.deepStrictEqual(changes, ["item-6"]);
|
||||
assert.strictEqual(selected, undefined);
|
||||
|
||||
list.handleMouse(mouse("press", 1, row));
|
||||
list.render(80);
|
||||
list.handleMouse(mouse("click", 1, row));
|
||||
assert.strictEqual(selected, `item-${4 + row}`);
|
||||
assert.deepStrictEqual(changes, ["item-6", `item-${4 + row}`]);
|
||||
});
|
||||
|
||||
it(`ignores hover and clicks visible settings row ${row} after scrolling`, () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `item-${i}`,
|
||||
label: `Item ${i}`,
|
||||
description: `Description ${i}`,
|
||||
currentValue: "off",
|
||||
values: ["off", "on"],
|
||||
})),
|
||||
5,
|
||||
settingsTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
list.selectItem("item-5");
|
||||
list.handleMouse({ ...mouse("wheel", 1, row + 2), wheelDelta: 1 });
|
||||
const before = list.render(80);
|
||||
assert.match(before[4], /^> Item 6/);
|
||||
assert.match(before[row + 2], new RegExp(`Item ${4 + row} `));
|
||||
|
||||
for (const y of [0, 1, 2, 3, 4, row]) {
|
||||
assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y + 2), button: "none" }), undefined);
|
||||
assert.deepStrictEqual(list.render(80), before);
|
||||
}
|
||||
assert.deepStrictEqual(changes, []);
|
||||
|
||||
list.handleMouse(mouse("press", 1, row + 2));
|
||||
list.render(80);
|
||||
list.handleMouse(mouse("click", 1, row + 2));
|
||||
assert.deepStrictEqual(changes, [{ id: `item-${4 + row}`, value: "on" }]);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a delegating overlay focused when its nested input is clicked", async () => {
|
||||
const terminal = new VirtualTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const overlay = new InputOverlay();
|
||||
overlay.input.setValue("hi");
|
||||
tui.start();
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 20 });
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;5;1M");
|
||||
terminal.sendInput("\x1b[<0;5;1m");
|
||||
terminal.sendInput("!");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(overlay.input.getValue(), "hi!");
|
||||
assert.strictEqual(tui.getFocusedComponent(), overlay);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.setText("hello");
|
||||
tui.addChild(editor);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.sendInput("\x1b[<0;3;2M");
|
||||
terminal.sendInput("\x1b[<0;3;2m");
|
||||
terminal.sendInput("X");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(editor.getText(), "heXllo");
|
||||
assert.strictEqual(tui.getFocusedComponent(), editor);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("selects and copies editor text on drag instead of moving the cursor", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const copied: string[] = [];
|
||||
const tui = new TuiAltScreen(terminal, undefined, undefined, {
|
||||
copySelection: async (text) => {
|
||||
copied.push(text);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
editor.setText("hello world");
|
||||
tui.addChild(editor);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const cursorBefore = editor.getCursor();
|
||||
|
||||
terminal.sendInput("\x1b[<0;1;2M");
|
||||
terminal.sendInput("\x1b[<32;5;2M");
|
||||
terminal.sendInput("\x1b[<0;5;2m");
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.deepStrictEqual(copied, ["hello"]);
|
||||
assert.deepStrictEqual(editor.getCursor(), cursorBefore);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
45
packages/tui/test/native-module-path.test.ts
Normal file
45
packages/tui/test/native-module-path.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getNativeModuleCandidates } from "../src/native-module-path.ts";
|
||||
|
||||
describe("getNativeModuleCandidates", () => {
|
||||
it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => {
|
||||
const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui");
|
||||
const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js");
|
||||
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node");
|
||||
|
||||
const candidates = getNativeModuleCandidates(nativePath, {
|
||||
moduleUrl: pathToFileURL(bundledModule).href,
|
||||
execPath: resolve("virtual", "node", "node.exe"),
|
||||
resolvePackage: (specifier) => {
|
||||
assert.equal(specifier, "@earendil-works/pi-tui");
|
||||
return join(packageRoot, "dist", "index.js");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(candidates[0], join(packageRoot, nativePath));
|
||||
assert.ok(candidates.includes(join(dirname(bundledModule), "..", nativePath)));
|
||||
});
|
||||
|
||||
it("keeps standalone binary fallbacks when the TUI package is unavailable", () => {
|
||||
const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js");
|
||||
const execPath = resolve("virtual", "pi", "pi.exe");
|
||||
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node");
|
||||
|
||||
const candidates = getNativeModuleCandidates(nativePath, {
|
||||
moduleUrl: pathToFileURL(bundledModule).href,
|
||||
execPath,
|
||||
resolvePackage: () => {
|
||||
throw new Error("not installed");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(candidates, [
|
||||
join(dirname(bundledModule), "..", nativePath),
|
||||
join(dirname(bundledModule), nativePath),
|
||||
join(dirname(execPath), nativePath),
|
||||
]);
|
||||
});
|
||||
});
|
||||
1203
packages/tui/test/overlay-non-capturing.test.ts
Normal file
1203
packages/tui/test/overlay-non-capturing.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
541
packages/tui/test/overlay-options.test.ts
Normal file
541
packages/tui/test/overlay-options.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
private lines: string[];
|
||||
requestedWidth?: number;
|
||||
|
||||
constructor(lines: string[], requestedWidth?: number) {
|
||||
this.lines = lines;
|
||||
this.requestedWidth = requestedWidth;
|
||||
}
|
||||
|
||||
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.waitForRender();
|
||||
}
|
||||
|
||||
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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
62
packages/tui/test/overlay-short-content.test.ts
Normal file
62
packages/tui/test/overlay-short-content.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class SimpleContent implements Component {
|
||||
private lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
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: TUI = new TuiMainScreen(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 terminal.waitForRender();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
46
packages/tui/test/regression-overlay-cjk-boundary.test.ts
Normal file
46
packages/tui/test/regression-overlay-cjk-boundary.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { compositeTuiLine } from "../src/tui.ts";
|
||||
import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
describe("overlay CJK boundary regression", () => {
|
||||
it("excludes a wide grapheme from before when overlay starts inside it", () => {
|
||||
const segments = extractSegments("abcd让EFGH", 5, 9, 11, true);
|
||||
|
||||
assert.strictEqual(segments.before, "abcd");
|
||||
assert.strictEqual(segments.beforeWidth, 4);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
assert.strictEqual(segments.after, "H");
|
||||
assert.strictEqual(segments.afterWidth, 1);
|
||||
});
|
||||
|
||||
it("keeps ASCII before-segment behavior at the same boundary", () => {
|
||||
const segments = extractSegments("abcdG EFGH", 5, 9, 11, true);
|
||||
|
||||
assert.strictEqual(segments.before, "abcdG");
|
||||
assert.strictEqual(segments.beforeWidth, 5);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
});
|
||||
|
||||
it("composites an overlay at the requested column when it starts inside a wide grapheme", () => {
|
||||
const out = compositeTuiLine("abcd让EFGH", "│XX│", 5, 4, 20);
|
||||
const prefix = sliceByColumn(out, 0, 5, true);
|
||||
const overlay = sliceByColumn(out, 5, 4, true);
|
||||
|
||||
assert.strictEqual(out.includes("让"), false);
|
||||
assert.strictEqual(visibleWidth(out), 20);
|
||||
assert.strictEqual(visibleWidth(prefix), 5);
|
||||
assert.strictEqual(visibleWidth(overlay), 4);
|
||||
assert.strictEqual(overlay.includes("│XX│"), true);
|
||||
});
|
||||
|
||||
it("composites an overlay when it starts at a wide grapheme boundary", () => {
|
||||
const out = compositeTuiLine("abcd让EFGH", "│XX│", 4, 4, 20);
|
||||
const overlay = sliceByColumn(out, 4, 4, true);
|
||||
|
||||
assert.strictEqual(out.includes("让"), false);
|
||||
assert.strictEqual(visibleWidth(out), 20);
|
||||
assert.strictEqual(visibleWidth(overlay), 4);
|
||||
assert.strictEqual(overlay.includes("│XX│"), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts";
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
62
packages/tui/test/regression-sigwinch-kill-eacces.test.ts
Normal file
62
packages/tui/test/regression-sigwinch-kill-eacces.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert";
|
||||
import { after, describe, it } from "node:test";
|
||||
import { refreshTerminalDimensions } from "../src/terminal.ts";
|
||||
|
||||
describe("refreshTerminalDimensions", () => {
|
||||
const originalKill = process.kill;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
it("does not throw when kill(2) returns EACCES for self-signal", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return (pid, _signal) => {
|
||||
if (pid === process.pid) {
|
||||
const err = new Error("kill EACCES") as NodeJS.ErrnoException;
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
}
|
||||
return originalKill.call(process, pid, _signal);
|
||||
};
|
||||
})();
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
refreshTerminalDimensions();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call kill on win32", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
let killCalled = false;
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return () => {
|
||||
killCalled = true;
|
||||
return true;
|
||||
};
|
||||
})();
|
||||
|
||||
refreshTerminalDimensions();
|
||||
|
||||
assert.strictEqual(killCalled, false, "kill should not be called on win32");
|
||||
});
|
||||
|
||||
it("preserves other error codes", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux" });
|
||||
process.kill = ((): typeof process.kill => {
|
||||
return () => {
|
||||
const err = new Error("kill EPERM") as NodeJS.ErrnoException;
|
||||
err.code = "EPERM";
|
||||
throw err;
|
||||
};
|
||||
})();
|
||||
|
||||
// EPERM is also ignored - the refresh is best-effort
|
||||
assert.doesNotThrow(() => {
|
||||
refreshTerminalDimensions();
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
process.kill = originalKill;
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform });
|
||||
});
|
||||
});
|
||||
203
packages/tui/test/render-churn-bench.ts
Normal file
203
packages/tui/test/render-churn-bench.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Alt-screen render churn benchmark.
|
||||
*
|
||||
* Measures cumulative JS allocation and wall time for repeated TuiAltScreen
|
||||
* frames on a layout mirroring pi's fullscreen interactive mode:
|
||||
* VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ].
|
||||
*
|
||||
* Two scenarios:
|
||||
* - static: nothing changes between frames (pure recomposite churn)
|
||||
* - editor: one character appended to the editor per frame (doc scenario
|
||||
* "30 editor updates")
|
||||
*
|
||||
* Allocation is estimated with the V8 sampling heap profiler including
|
||||
* objects collected by minor/major GC, i.e. it measures churn, not retention.
|
||||
*
|
||||
* Run from packages/tui: node test/render-churn-bench.ts
|
||||
*/
|
||||
|
||||
import { Session } from "node:inspector/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { ScrollView } from "../src/components/scroll-view.ts";
|
||||
import { Text } from "../src/components/text.ts";
|
||||
import { VStack } from "../src/components/v-stack.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts";
|
||||
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
|
||||
|
||||
const COLUMNS = 100;
|
||||
const ROWS = 30;
|
||||
const WARMUP_FRAMES = 20;
|
||||
const FRAMES = 300;
|
||||
const SAMPLING_INTERVAL = 4096;
|
||||
|
||||
/** Terminal that discards output; keeps xterm parsing out of the measurement. */
|
||||
class NullTerminal implements Terminal {
|
||||
bytesWritten = 0;
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.bytesWritten += data.length;
|
||||
}
|
||||
get columns(): number {
|
||||
return COLUMNS;
|
||||
}
|
||||
get rows(): number {
|
||||
return ROWS;
|
||||
}
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */
|
||||
class EditorSim implements Component {
|
||||
private text = "";
|
||||
private cachedText?: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
|
||||
append(char: string): void {
|
||||
this.text += char;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`;
|
||||
const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border];
|
||||
this.cachedText = this.text;
|
||||
this.cachedWidth = width;
|
||||
this.cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTranscript(): Container {
|
||||
const container = new Container();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const styled =
|
||||
i % 3 === 0
|
||||
? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding`
|
||||
: `assistant ${i} plain response line with enough text to be representative of a transcript row`;
|
||||
container.addChild(new Text(styled, 1, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
interface SamplingNode {
|
||||
selfSize: number;
|
||||
children: SamplingNode[];
|
||||
}
|
||||
|
||||
function sumProfile(node: SamplingNode): number {
|
||||
let total = node.selfSize;
|
||||
for (const child of node.children) total += sumProfile(child);
|
||||
return total;
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
allocatedBytes: number;
|
||||
elapsedMs: number;
|
||||
bytesWritten: number;
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
session: Session,
|
||||
terminal: NullTerminal,
|
||||
tui: TuiAltScreen,
|
||||
frame: (index: number) => void,
|
||||
): Promise<ScenarioResult> {
|
||||
const writtenBefore = terminal.bytesWritten;
|
||||
await session.post("HeapProfiler.startSampling", {
|
||||
samplingInterval: SAMPLING_INTERVAL,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < FRAMES; i++) {
|
||||
frame(i);
|
||||
tui.renderNow();
|
||||
}
|
||||
const elapsedMs = performance.now() - start;
|
||||
const { profile } = await session.post("HeapProfiler.stopSampling");
|
||||
return {
|
||||
allocatedBytes: sumProfile(profile.head as SamplingNode),
|
||||
elapsedMs,
|
||||
bytesWritten: terminal.bytesWritten - writtenBefore,
|
||||
};
|
||||
}
|
||||
|
||||
function report(name: string, result: ScenarioResult): void {
|
||||
const perFrameKiB = result.allocatedBytes / FRAMES / 1024;
|
||||
const totalMiB = result.allocatedBytes / 1024 / 1024;
|
||||
const msPerFrame = result.elapsedMs / FRAMES;
|
||||
console.log(
|
||||
`${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` +
|
||||
`${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` +
|
||||
`${msPerFrame.toFixed(3).padStart(7)} ms/frame ` +
|
||||
`${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const terminal = new NullTerminal();
|
||||
const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench");
|
||||
|
||||
const transcript = buildTranscript();
|
||||
const editor = new EditorSim();
|
||||
const scrollView = new ScrollView(transcript, {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
overscroll: "chain",
|
||||
scrollbar: "auto",
|
||||
});
|
||||
const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0);
|
||||
const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0);
|
||||
const dock = new VStack([
|
||||
{ component: status, shrink: 1, minSize: 0 },
|
||||
{ component: editor, shrink: 1, minSize: 3 },
|
||||
{ component: footer, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
const root = new VStack([
|
||||
{ component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
tui.setLayoutRoot(root);
|
||||
tui.start();
|
||||
|
||||
for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow();
|
||||
|
||||
const session = new Session();
|
||||
session.connect();
|
||||
|
||||
const staticResult = await runScenario(session, terminal, tui, () => {});
|
||||
const editorResult = await runScenario(session, terminal, tui, (i) => {
|
||||
editor.append(String.fromCharCode(97 + (i % 26)));
|
||||
});
|
||||
|
||||
session.disconnect();
|
||||
tui.stop();
|
||||
|
||||
console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`);
|
||||
report("static", staticResult);
|
||||
report("editor", editorResult);
|
||||
}
|
||||
|
||||
await main();
|
||||
116
packages/tui/test/select-list.test.ts
Normal file
116
packages/tui/test/select-list.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { SelectList } from "../src/components/select-list.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
const testTheme = {
|
||||
selectedPrefix: (text: string) => text,
|
||||
selectedText: (text: string) => text,
|
||||
description: (text: string) => text,
|
||||
scrollInfo: (text: string) => text,
|
||||
noMatch: (text: string) => text,
|
||||
};
|
||||
|
||||
const visibleIndexOf = (line: string, text: string): number => {
|
||||
const index = line.indexOf(text);
|
||||
assert.notEqual(index, -1);
|
||||
return visibleWidth(line.slice(0, index));
|
||||
};
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
it("keeps descriptions aligned when the primary text is truncated", () => {
|
||||
const items = [
|
||||
{ value: "short", label: "short", description: "short description" },
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "long description",
|
||||
},
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme);
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(visibleIndexOf(rendered[0], "short description"), visibleIndexOf(rendered[1], "long description"));
|
||||
});
|
||||
|
||||
it("uses the configured minimum primary column width", () => {
|
||||
const items = [
|
||||
{ value: "a", label: "a", description: "first" },
|
||||
{ value: "bb", label: "bb", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 20,
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(rendered[0].indexOf("first"), 14);
|
||||
assert.equal(rendered[1].indexOf("second"), 14);
|
||||
});
|
||||
|
||||
it("uses the configured maximum primary column width", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "first",
|
||||
},
|
||||
{ value: "short", label: "short", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 20,
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.equal(visibleIndexOf(rendered[0], "first"), 22);
|
||||
assert.equal(visibleIndexOf(rendered[1], "second"), 22);
|
||||
});
|
||||
|
||||
it("allows overriding primary truncation while preserving description alignment", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "very-long-command-name-that-needs-truncation",
|
||||
label: "very-long-command-name-that-needs-truncation",
|
||||
description: "first",
|
||||
},
|
||||
{ value: "short", label: "short", description: "second" },
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme, {
|
||||
minPrimaryColumnWidth: 12,
|
||||
maxPrimaryColumnWidth: 12,
|
||||
truncatePrimary: ({ text, maxWidth }) => {
|
||||
if (text.length <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, maxWidth - 1))}…`;
|
||||
},
|
||||
});
|
||||
const rendered = list.render(80);
|
||||
|
||||
assert.ok(rendered[0].includes("…"));
|
||||
assert.equal(visibleIndexOf(rendered[0], "first"), visibleIndexOf(rendered[1], "second"));
|
||||
});
|
||||
});
|
||||
58
packages/tui/test/settings-list.test.ts
Normal file
58
packages/tui/test/settings-list.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts";
|
||||
|
||||
const testTheme: SettingsListTheme = {
|
||||
label: (text) => text,
|
||||
value: (text) => text,
|
||||
description: (text) => text,
|
||||
cursor: "> ",
|
||||
hint: (text) => text,
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "tui-mode",
|
||||
label: "TUI mode",
|
||||
currentValue: "regular",
|
||||
values: ["regular", "fullscreen"],
|
||||
},
|
||||
];
|
||||
|
||||
describe("SettingsList", () => {
|
||||
it("includes spaces in an active search instead of changing the selected setting", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
items.map((item) => ({ ...item })),
|
||||
10,
|
||||
testTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
|
||||
for (const character of "TUI mode") list.handleInput(character);
|
||||
|
||||
assert.deepStrictEqual(changes, []);
|
||||
assert.match(list.render(80)[0] ?? "", /TUI mode/);
|
||||
|
||||
list.handleInput("\r");
|
||||
assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]);
|
||||
});
|
||||
|
||||
it("keeps Space as a change shortcut before a search query is entered", () => {
|
||||
const changes: Array<{ id: string; value: string }> = [];
|
||||
const list = new SettingsList(
|
||||
items.map((item) => ({ ...item })),
|
||||
10,
|
||||
testTheme,
|
||||
(id, value) => changes.push({ id, value }),
|
||||
() => {},
|
||||
{ enableSearch: true },
|
||||
);
|
||||
|
||||
list.handleInput(" ");
|
||||
|
||||
assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]);
|
||||
});
|
||||
});
|
||||
526
packages/tui/test/stdin-buffer.test.ts
Normal file
526
packages/tui/test/stdin-buffer.test.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* 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 { matchesKey } from "../src/keys.ts";
|
||||
import { StdinBuffer } from "../src/stdin-buffer.ts";
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => {
|
||||
// Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits
|
||||
// the bytes further apart than the timeout, ESC is flushed alone and the
|
||||
// host sees Escape (interrupt) instead of Alt+Enter. This locks in the
|
||||
// behavior so the configurable timeout in ProcessTerminal stays honest.
|
||||
processInput("\x1b");
|
||||
await wait(20); // buffer timeout is 10ms in beforeEach
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true);
|
||||
});
|
||||
|
||||
it("should merge ESC + CR split across chunks within a larger timeout", async () => {
|
||||
buffer = new StdinBuffer({ escapeTimeout: 100 });
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
processInput("\x1b");
|
||||
await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true);
|
||||
});
|
||||
|
||||
it("does not apply the sequence timeout to a lone ESC", async () => {
|
||||
buffer = new StdinBuffer({ timeout: 100 });
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
processInput("\x1b");
|
||||
await wait(20);
|
||||
processInput("\r");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]);
|
||||
assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true);
|
||||
});
|
||||
|
||||
it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => {
|
||||
const delayedBuffer = new StdinBuffer();
|
||||
const delayedSequences: string[] = [];
|
||||
delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence));
|
||||
|
||||
delayedBuffer.process("\x1b[");
|
||||
await wait(20);
|
||||
assert.deepStrictEqual(delayedSequences, []);
|
||||
delayedBuffer.process("<65;48;39M");
|
||||
assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]);
|
||||
delayedBuffer.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
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 split ESC+ESC+CSI into standalone ESC and the CSI sequence (WezTerm Escape key regression)", () => {
|
||||
// WezTerm with enable_kitty_keyboard sends Escape key press as raw \x1b
|
||||
// and the release as a full Kitty CSI-u sequence, concatenated.
|
||||
// The buffer must not treat \x1b\x1b as a complete meta-key when the
|
||||
// following byte starts a new escape sequence.
|
||||
processInput("\x1b\x1b[27;129:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;129:3u"]);
|
||||
});
|
||||
|
||||
it("should split ESC+ESC+CSI with no modifier (no num_lock)", () => {
|
||||
processInput("\x1b\x1b[27;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b", "\x1b[27;1:3u"]);
|
||||
});
|
||||
|
||||
it("should still emit ESC+ESC as a single sequence when not followed by a new escape", () => {
|
||||
// \x1b\x1b alone (no following CSI) stays as-is — e.g. ctrl+alt+[
|
||||
processInput("\x1b\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b\x1b"]);
|
||||
});
|
||||
|
||||
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 drop raw duplicate character after matching Kitty printable sequence", () => {
|
||||
processInput("\x1b[224uà");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[224u"]);
|
||||
});
|
||||
|
||||
it("should drop raw duplicate character after matching Kitty printable sequence across chunks", () => {
|
||||
processInput("\x1b[64u");
|
||||
processInput("@");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[64u"]);
|
||||
});
|
||||
|
||||
it("should keep non-matching plain character after Kitty printable sequence", () => {
|
||||
processInput("\x1b[97ub");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "b"]);
|
||||
});
|
||||
|
||||
it("should keep raw character after modified Kitty printable sequence", () => {
|
||||
processInput("\x1b[64;3u@");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[64;3u", "@"]);
|
||||
});
|
||||
|
||||
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("flushes a lone escape promptly with the longer default sequence timeout", async () => {
|
||||
const defaultBuffer = new StdinBuffer();
|
||||
const defaultSequences: string[] = [];
|
||||
defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence));
|
||||
|
||||
defaultBuffer.process("\x1b");
|
||||
await wait(20);
|
||||
assert.deepStrictEqual(defaultSequences, ["\x1b"]);
|
||||
defaultBuffer.destroy();
|
||||
});
|
||||
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
packages/tui/test/tab-width.test.ts
Normal file
88
packages/tui/test/tab-width.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class FullViewportContent implements Component {
|
||||
render(width: number): string[] {
|
||||
return ["base 0", "base 1", "base 2"].map((line) => line.padEnd(width));
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class CapturingVirtualTerminal extends VirtualTerminal {
|
||||
private output = "";
|
||||
|
||||
override write(data: string): void {
|
||||
this.output += data;
|
||||
super.write(data);
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
return this.output;
|
||||
}
|
||||
}
|
||||
|
||||
class TabStatusOverlay implements Component {
|
||||
render(): string[] {
|
||||
return ["\tX"];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
describe("tab width accounting", () => {
|
||||
it("keeps slice helper widths consistent with visible width", () => {
|
||||
const text = "out 192M\t.pi/skill-tests/results-ha";
|
||||
const slice = sliceWithWidth(text, 0, 10, true);
|
||||
|
||||
assert.strictEqual(slice.text, "out 192M");
|
||||
assert.strictEqual(slice.width, 8);
|
||||
assert.strictEqual(visibleWidth(slice.text), slice.width);
|
||||
});
|
||||
|
||||
it("keeps overlay segment widths consistent with visible width", () => {
|
||||
const text = "out 192M\t.pi/skill-tests/results-ha";
|
||||
const segments = extractSegments(text, 10, 13, 10, true);
|
||||
|
||||
assert.strictEqual(segments.before, "out 192M");
|
||||
assert.strictEqual(segments.beforeWidth, 8);
|
||||
assert.strictEqual(visibleWidth(segments.before), segments.beforeWidth);
|
||||
|
||||
const tabFits = extractSegments(text, 11, 13, 10, true);
|
||||
assert.strictEqual(tabFits.before, "out 192M\t");
|
||||
assert.strictEqual(tabFits.beforeWidth, 11);
|
||||
assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth);
|
||||
});
|
||||
|
||||
it("keeps tabs inside terminal control sequences byte-identical", () => {
|
||||
const controlSequences = [
|
||||
"\x1b]8;;https://example.test/a\tb\x07",
|
||||
"\x1b]0;window\ttitle\x1b\\",
|
||||
"\x1b_payload\tdata\x1b\\",
|
||||
];
|
||||
|
||||
for (const controlSequence of controlSequences) {
|
||||
assert.strictEqual(normalizeTerminalOutput(`${controlSequence}label\ttext`), `${controlSequence}label text`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps tab-containing overlays on one physical terminal row", async () => {
|
||||
const terminal = new CapturingVirtualTerminal(16, 3);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.addChild(new FullViewportContent());
|
||||
tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 });
|
||||
tui.start();
|
||||
|
||||
try {
|
||||
await terminal.waitForRender();
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["base 0 ", "base X ", "base 2 "]);
|
||||
assert.ok(!terminal.getOutput().includes("\t"));
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
252
packages/tui/test/terminal-colors.test.ts
Normal file
252
packages/tui/test/terminal-colors.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
type Component,
|
||||
parseOsc11BackgroundColor,
|
||||
parseTerminalColorSchemeReport,
|
||||
type Terminal,
|
||||
type TUI,
|
||||
TuiMainScreen,
|
||||
} from "../src/index.ts";
|
||||
|
||||
class TestTerminal implements Terminal {
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private readonly columnCount: number;
|
||||
private readonly rowCount: number;
|
||||
readonly writes: string[] = [];
|
||||
|
||||
constructor(columnCount = 80, rowCount = 24) {
|
||||
this.columnCount = columnCount;
|
||||
this.rowCount = rowCount;
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.inputHandler = undefined;
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
|
||||
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.columnCount;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.rowCount;
|
||||
}
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
moveBy(_lines: number): void {}
|
||||
|
||||
hideCursor(): void {}
|
||||
|
||||
showCursor(): void {}
|
||||
|
||||
clearLine(): void {}
|
||||
|
||||
clearFromCursor(): void {}
|
||||
|
||||
clearScreen(): void {}
|
||||
|
||||
setTitle(_title: string): void {}
|
||||
|
||||
setProgress(_active: boolean): void {}
|
||||
|
||||
sendInput(data: string): void {
|
||||
this.inputHandler?.(data);
|
||||
}
|
||||
|
||||
sendResize(): void {
|
||||
this.resizeHandler?.();
|
||||
}
|
||||
}
|
||||
|
||||
class InputRecorder implements Component {
|
||||
readonly inputs: string[] = [];
|
||||
|
||||
render(_width: number): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputs.push(data);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe("parseOsc11BackgroundColor", () => {
|
||||
it("parses 16-bit OSC 11 rgb responses", () => {
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), {
|
||||
r: 0,
|
||||
g: 128,
|
||||
b: 255,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses OSC 11 hex responses", () => {
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 });
|
||||
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 });
|
||||
});
|
||||
|
||||
it("rejects non-strict OSC 11 responses", () => {
|
||||
assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined);
|
||||
assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined);
|
||||
assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTerminalColorSchemeReport", () => {
|
||||
it("parses color scheme reports", () => {
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n\x1b[?997;1n\x1b[?997;1n"), "dark");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n\x1b[?997;2n\x1b[?997;2n"), "light");
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined);
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined);
|
||||
assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI.queryTerminalBackgroundColor", () => {
|
||||
it("writes OSC 11 query and resolves with the parsed RGB reply", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
assert.ok(terminal.writes.includes("\x1b]11;?\x07"));
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
|
||||
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
|
||||
terminal.sendInput("\x1b]11;#000000\x07");
|
||||
|
||||
assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 });
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
|
||||
|
||||
terminal.sendInput("\x1b]11;not-a-color\x07");
|
||||
|
||||
assert.strictEqual(await query, undefined);
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
let settled = false;
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => {
|
||||
settled = true;
|
||||
return rgb;
|
||||
});
|
||||
|
||||
terminal.sendInput("x");
|
||||
await Promise.resolve();
|
||||
|
||||
assert.strictEqual(settled, false);
|
||||
assert.deepStrictEqual(listenerInputs, ["x"]);
|
||||
assert.deepStrictEqual(component.inputs, ["x"]);
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps consuming a late OSC 11 reply after timeout", async () => {
|
||||
const terminal = new TestTerminal();
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputRecorder();
|
||||
const listenerInputs: string[] = [];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.addInputListener((data) => {
|
||||
listenerInputs.push(data);
|
||||
return undefined;
|
||||
});
|
||||
tui.start();
|
||||
try {
|
||||
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 });
|
||||
await wait(5);
|
||||
|
||||
assert.strictEqual(await query, undefined);
|
||||
|
||||
terminal.sendInput("\x1b]11;#ffffff\x07");
|
||||
|
||||
assert.deepStrictEqual(listenerInputs, []);
|
||||
assert.deepStrictEqual(component.inputs, []);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
700
packages/tui/test/terminal-image.test.ts
Normal file
700
packages/tui/test/terminal-image.test.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
/**
|
||||
* Tests for terminal image detection and line handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import {
|
||||
cropKittyImageLine,
|
||||
deleteAllKittyImages,
|
||||
deleteAllKittyPlacements,
|
||||
deleteKittyImage,
|
||||
detectCapabilities,
|
||||
encodeITerm2,
|
||||
encodeKitty,
|
||||
getCapabilities,
|
||||
getKittyImageMetadata,
|
||||
getKittyImagePlacement,
|
||||
hyperlink,
|
||||
imageFallback,
|
||||
isImageLine,
|
||||
registerKittyImageMetadata,
|
||||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCapabilityOverrides,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"TERMINAL_EMULATOR",
|
||||
"COLORTERM",
|
||||
"TMUX",
|
||||
"KITTY_WINDOW_ID",
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
"WEZTERM_PANE",
|
||||
"ITERM_SESSION_ID",
|
||||
"WT_SESSION",
|
||||
"CMUX_WORKSPACE_ID",
|
||||
"WARP_SESSION_ID",
|
||||
"WARP_TERMINAL_SESSION_UUID",
|
||||
"PI_HYPERLINKS",
|
||||
"PI_IMAGE_PROTOCOL",
|
||||
"PI_TRUE_COLOR",
|
||||
] as const;
|
||||
|
||||
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
return fn();
|
||||
} finally {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectCapabilities", () => {
|
||||
it("defaults to hyperlinks: false for unknown terminals", () => {
|
||||
withEnv({}, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("applies environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => detectCapabilities()),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
withEnv({ TERM_PROGRAM: "iterm.app", PI_HYPERLINKS: "0", PI_IMAGE_PROTOCOL: "none", PI_TRUE_COLOR: "0" }, () =>
|
||||
detectCapabilities(),
|
||||
),
|
||||
{ images: null, trueColor: false, hyperlinks: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves auto-detection for auto environment overrides", () => {
|
||||
assert.deepStrictEqual(
|
||||
withEnv(
|
||||
{
|
||||
TERM_PROGRAM: "ghostty",
|
||||
PI_HYPERLINKS: "auto",
|
||||
PI_IMAGE_PROTOCOL: "auto",
|
||||
PI_TRUE_COLOR: "auto",
|
||||
},
|
||||
() => detectCapabilities(),
|
||||
),
|
||||
{ images: "kitty", trueColor: true, hyperlinks: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("applies and clears programmatic overrides", () => {
|
||||
withEnv({ PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty", PI_TRUE_COLOR: "1" }, () => {
|
||||
setCapabilityOverrides({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
assert.deepStrictEqual(getCapabilities(), { images: null, trueColor: false, hyperlinks: false });
|
||||
setCapabilityOverrides({});
|
||||
assert.deepStrictEqual(getCapabilities(), { images: "kitty", trueColor: true, hyperlinks: true });
|
||||
} finally {
|
||||
setCapabilityOverrides({});
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses the tmux probe when hyperlinks are overridden", () => {
|
||||
let probed = false;
|
||||
const caps = withEnv(
|
||||
{ TMUX: "/tmp/tmux-1000/default,1234,0", PI_HYPERLINKS: "1", PI_IMAGE_PROTOCOL: "kitty" },
|
||||
() =>
|
||||
detectCapabilities(() => {
|
||||
probed = true;
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
assert.strictEqual(probed, false);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
});
|
||||
|
||||
it("enables hyperlinks under tmux when the client forwards them", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables hyperlinks under tmux when the client does not forward them", () => {
|
||||
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("checks tmux capability when TERM starts with 'tmux'", () => {
|
||||
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
|
||||
const caps2 = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps2.hyperlinks, false);
|
||||
});
|
||||
});
|
||||
|
||||
it("forces hyperlinks: false when TERM starts with 'screen'", () => {
|
||||
withEnv({ TERM: "screen-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Ghostty", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not disable Ghostty images solely because cmux is present", () => {
|
||||
withEnv({ TERM_PROGRAM: "ghostty", CMUX_WORKSPACE_ID: "workspace" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for Kitty", () => {
|
||||
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for WezTerm", () => {
|
||||
withEnv({ WEZTERM_PANE: "0" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via TERM_PROGRAM", () => {
|
||||
withEnv({ TERM_PROGRAM: "WarpTerminal" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via WARP_SESSION_ID", () => {
|
||||
withEnv({ WARP_SESSION_ID: "some-session-id" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables images and hyperlinks for Warp via WARP_TERMINAL_SESSION_UUID", () => {
|
||||
withEnv({ WARP_TERMINAL_SESSION_UUID: "d0e1a2e5-7ca7-44cd-9037-ac7222011161" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.images, "kitty");
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables images for Warp inside tmux", () => {
|
||||
withEnv(
|
||||
{
|
||||
TERM_PROGRAM: "WarpTerminal",
|
||||
TMUX: "/tmp/tmux-1000/default,1234,0",
|
||||
TERM: "tmux-256color",
|
||||
},
|
||||
() => {
|
||||
const caps = detectCapabilities(() => true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("enables hyperlinks for iTerm2", () => {
|
||||
withEnv({ TERM_PROGRAM: "iterm.app" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables hyperlinks for VSCode", () => {
|
||||
withEnv({ TERM_PROGRAM: "vscode" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Alacritty capabilities for Zed", () => {
|
||||
withEnv({ TERM_PROGRAM: "zed" }, () => {
|
||||
assert.deepStrictEqual(detectCapabilities(), { images: null, trueColor: true, hyperlinks: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("enables truecolor and hyperlinks for Windows Terminal outside multiplexers", () => {
|
||||
withEnv({ WT_SESSION: "session", TERM: "xterm-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, true);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("enables truecolor without hyperlinks for JetBrains terminal", () => {
|
||||
withEnv({ TERMINAL_EMULATOR: "JetBrains-JediTerm", TERM: "xterm-256color" }, () => {
|
||||
const caps = detectCapabilities();
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inherit Windows Terminal truecolor through tmux", () => {
|
||||
withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.trueColor, false);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts explicit truecolor hints through tmux", () => {
|
||||
withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
||||
const caps = detectCapabilities(() => false);
|
||||
assert.strictEqual(caps.trueColor, true);
|
||||
assert.strictEqual(caps.hyperlinks, false);
|
||||
assert.strictEqual(caps.images, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("iTerm2 image encoding", () => {
|
||||
it("includes the decoded payload size in OSC 1337 metadata", () => {
|
||||
const sequence = encodeITerm2("AAAA", { width: 2, height: "auto" });
|
||||
assert.strictEqual(sequence, "\x1b]1337;File=inline=1;size=3;width=2;height=auto:AAAA\x07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty image cursor movement", () => {
|
||||
it("can request no terminal-side cursor movement", () => {
|
||||
const sequence = encodeKitty("AAAA", { columns: 2, rows: 2, moveCursor: false });
|
||||
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
|
||||
});
|
||||
|
||||
it("suppresses Kitty replies for delete commands", () => {
|
||||
assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\");
|
||||
assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\");
|
||||
assert.strictEqual(deleteAllKittyPlacements(), "\x1b_Ga=d,d=a,q=2\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves renderImage's default terminal-side cursor movement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2 });
|
||||
assert.ok(result);
|
||||
assert.ok(!result.sequence.includes(",C=1,"));
|
||||
assert.strictEqual(result.rows, 2);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("can opt renderImage into no terminal-side cursor movement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 20, heightPx: 20 }, { maxWidthCells: 2, moveCursor: false });
|
||||
assert.ok(result);
|
||||
assert.ok(result.sequence.includes(",C=1,"));
|
||||
assert.strictEqual(result.rows, 2);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("registers metadata and crops a partially visible placement", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage(
|
||||
"AAAA",
|
||||
{ widthPx: 100, heightPx: 100 },
|
||||
{ maxWidthCells: 3, imageId: 42, moveCursor: false },
|
||||
);
|
||||
assert.ok(result);
|
||||
assert.deepStrictEqual(getKittyImageMetadata(result.sequence), {
|
||||
imageId: 42,
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
widthPx: 100,
|
||||
heightPx: 100,
|
||||
});
|
||||
assert.ok(cropKittyImageLine(result.sequence, 2, 1).includes("y=66,h=34,r=1"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates placement-only commands for uploaded and cropped images", () => {
|
||||
registerKittyImageMetadata({ imageId: 42, columns: 3, rows: 3, widthPx: 100, heightPx: 100 });
|
||||
const transmission = encodeKitty("A".repeat(8192), {
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
imageId: 42,
|
||||
moveCursor: false,
|
||||
});
|
||||
const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`;
|
||||
const placement = getKittyImagePlacement(line);
|
||||
assert.ok(placement);
|
||||
assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length);
|
||||
assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4);
|
||||
assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\");
|
||||
assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`);
|
||||
assert.ok(!placement.replacementLine.includes("AAAA"));
|
||||
});
|
||||
|
||||
it("honors maxHeightCells by reducing rendered width", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const result = renderImage("AAAA", { widthPx: 10, heightPx: 100 }, { maxWidthCells: 10, maxHeightCells: 5 });
|
||||
assert.ok(result);
|
||||
assert.strictEqual(result.rows, 5);
|
||||
assert.ok(result.sequence.includes(",c=1,r=5"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("caps Image component height to a square pixel box by default", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 20 });
|
||||
try {
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 10 },
|
||||
{ widthPx: 10, heightPx: 100 },
|
||||
);
|
||||
const lines = image.render(12);
|
||||
assert.strictEqual(lines.length, 5);
|
||||
assert.ok(lines[0].includes(",c=1,r=5"));
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("places image sequence on first line with empty padding rows", () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 2 },
|
||||
{ widthPx: 20, heightPx: 20 },
|
||||
);
|
||||
const lines = image.render(4);
|
||||
const imageId = image.getImageId();
|
||||
assert.strictEqual(typeof imageId, "number");
|
||||
assert.ok(lines[0].startsWith("\x1b_G"));
|
||||
assert.ok(lines[0].includes(",C=1,"));
|
||||
assert.ok(lines[0].includes(`,i=${imageId}`));
|
||||
assert.ok(lines[0].endsWith("\x1b\\"));
|
||||
assert.deepStrictEqual(lines.slice(1, lines.length), [""]);
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long image fallback lines to render width", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
const longPath = join(
|
||||
homedir(),
|
||||
"images",
|
||||
`${"generated-image-with-a-very-long-absolute-path".repeat(4)}.png`,
|
||||
);
|
||||
const width = 40;
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => `\x1b[33m${value}\x1b[0m` },
|
||||
{ filename: longPath },
|
||||
{ widthPx: 1280, heightPx: 720 },
|
||||
);
|
||||
const lines = image.render(width);
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.ok(
|
||||
visibleWidth(lines[0]) <= width,
|
||||
`fallback line wider than ${width}: visible=${visibleWidth(lines[0])} raw=${JSON.stringify(lines[0])}`,
|
||||
);
|
||||
assert.ok(lines[0].includes("..."), "expected ellipsis when truncating long fallback path");
|
||||
assert.ok(lines[0].includes("~"), "expected home-shortened path in fallback");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageFallback", () => {
|
||||
it("shortens home-prefixed absolute paths without hyperlinks", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
const abs = join(homedir(), ".pi", "agent", "shot.png");
|
||||
const result = imageFallback("image/png", { widthPx: 1280, heightPx: 720 }, abs);
|
||||
assert.strictEqual(result, "[Image: ~/.pi/agent/shot.png [image/png] 1280x720]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps shortened absolute paths in OSC 8 file links when hyperlinks are enabled", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
try {
|
||||
const abs = join(homedir(), ".pi", "agent", "shot.png");
|
||||
const result = imageFallback("image/png", { widthPx: 10, heightPx: 10 }, abs);
|
||||
assert.ok(result.includes("\x1b]8;;file://"), "expected OSC 8 file link");
|
||||
assert.ok(
|
||||
result.includes(abs.replaceAll("\\", "/")) || result.includes(abs),
|
||||
"file URL should target absolute path",
|
||||
);
|
||||
// Visible text must use ~/... not the expanded home path.
|
||||
const visible = result.replace(/\x1b\]8;;.*?\x1b\\/g, "");
|
||||
assert.strictEqual(visible, "[Image: ~/.pi/agent/shot.png [image/png] 10x10]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves bare basenames unchanged and does not hyperlink them", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: true });
|
||||
try {
|
||||
const result = imageFallback("image/png", { widthPx: 1, heightPx: 1 }, "clankolas.png");
|
||||
assert.strictEqual(result, "[Image: clankolas.png [image/png] 1x1]");
|
||||
assert.ok(!result.includes("\x1b]8;"), "basename must not be hyperlinked");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits filename segment when not provided", () => {
|
||||
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
|
||||
try {
|
||||
assert.strictEqual(imageFallback("image/png", { widthPx: 8, heightPx: 6 }), "[Image: [image/png] 8x6]");
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hyperlink", () => {
|
||||
it("wraps text in OSC 8 open and close sequences", () => {
|
||||
const result = hyperlink("click me", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("preserves ANSI styling inside the hyperlink", () => {
|
||||
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
|
||||
const result = hyperlink(styled, "https://example.com");
|
||||
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
|
||||
assert.ok(result.includes(styled));
|
||||
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
|
||||
});
|
||||
|
||||
it("works with empty text", () => {
|
||||
const result = hyperlink("", "https://example.com");
|
||||
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
|
||||
});
|
||||
|
||||
it("works with file:// URIs", () => {
|
||||
const result = hyperlink("README.md", "file:///home/user/README.md");
|
||||
assert.ok(result.includes("file:///home/user/README.md"));
|
||||
assert.ok(result.includes("README.md"));
|
||||
});
|
||||
});
|
||||
300
packages/tui/test/terminal.test.ts
Normal file
300
packages/tui/test/terminal.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it, mock } from "node:test";
|
||||
import { setKittyProtocolActive } from "../src/keys.ts";
|
||||
import {
|
||||
normalizeAppleTerminalInput,
|
||||
normalizeNativeShiftEnterInput,
|
||||
ProcessTerminal,
|
||||
resolveEscapeTimeoutMs,
|
||||
} from "../src/terminal.ts";
|
||||
|
||||
describe("resolveEscapeTimeoutMs", () => {
|
||||
it("uses PI_TUI_ESC_TIMEOUT when configured", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80);
|
||||
});
|
||||
|
||||
it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10);
|
||||
assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10);
|
||||
});
|
||||
|
||||
it("defaults to 100ms over SSH", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100);
|
||||
assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100);
|
||||
});
|
||||
|
||||
it("defaults to 10ms otherwise", () => {
|
||||
assert.equal(resolveEscapeTimeoutMs({}), 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeNativeShiftEnterInput", () => {
|
||||
it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", true, true), "\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("leaves Return unchanged when native Shift detection is disabled", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", false, true), "\r");
|
||||
});
|
||||
|
||||
it("leaves Return unchanged when Shift is not pressed", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\r", true, false), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Return input unchanged", () => {
|
||||
assert.equal(normalizeNativeShiftEnterInput("\x1b[13;2u", true, true), "\x1b[13;2u");
|
||||
assert.equal(normalizeNativeShiftEnterInput("a", true, true), "a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAppleTerminalInput", () => {
|
||||
it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u");
|
||||
});
|
||||
|
||||
it("leaves Apple Terminal Return unchanged when Shift is not pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", true, false), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Apple Terminal Return unchanged when Shift is pressed", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\r", false, true), "\r");
|
||||
});
|
||||
|
||||
it("leaves non-Return input unchanged", () => {
|
||||
assert.equal(normalizeAppleTerminalInput("\x1b[13;2u", true, true), "\x1b[13;2u");
|
||||
assert.equal(normalizeAppleTerminalInput("a", true, true), "a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
|
||||
type NegotiationHarness = {
|
||||
terminal: ProcessTerminal;
|
||||
writes: string[];
|
||||
send(data: string): void;
|
||||
getInput(): string | undefined;
|
||||
cleanup(): void;
|
||||
};
|
||||
|
||||
function setupNegotiation(): NegotiationHarness {
|
||||
const terminal = new ProcessTerminal();
|
||||
const writes: string[] = [];
|
||||
let input: string | undefined;
|
||||
let dataHandler: ((data: string) => void) | undefined;
|
||||
let cleaned = false;
|
||||
const previousWrite = process.stdout.write;
|
||||
const previousOn = process.stdin.on;
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
process.stdin.on = ((event: string | symbol, listener: (...args: unknown[]) => void) => {
|
||||
if (event === "data") dataHandler = listener as (data: string) => void;
|
||||
return process.stdin;
|
||||
}) as typeof process.stdin.on;
|
||||
|
||||
(
|
||||
terminal as unknown as {
|
||||
inputHandler?: (data: string) => void;
|
||||
queryAndEnableKittyProtocol(): void;
|
||||
}
|
||||
).inputHandler = (data) => {
|
||||
input = data;
|
||||
};
|
||||
(terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol();
|
||||
|
||||
return {
|
||||
terminal,
|
||||
writes,
|
||||
send(data: string): void {
|
||||
dataHandler?.(data);
|
||||
},
|
||||
getInput(): string | undefined {
|
||||
return input;
|
||||
},
|
||||
cleanup(): void {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
try {
|
||||
terminal.stop();
|
||||
} finally {
|
||||
process.stdout.write = previousWrite;
|
||||
process.stdin.on = previousOn;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("queries Kitty mode before enabling modifyOtherKeys fallback", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("activates Kitty mode for non-zero negotiated flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7u");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[<u").length, 1);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;0m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for zero Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?0u");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
|
||||
harness.cleanup();
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;0m").length, 1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for device attributes without Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?62;4;52c");
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>4;2m").length, 1);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards normal input while waiting for Kitty response", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("a");
|
||||
|
||||
assert.equal(harness.getInput(), "a");
|
||||
assert.equal(harness.terminal.kittyProtocolActive, false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("tracks split Kitty confirmation", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7");
|
||||
mock.timers.tick(10);
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
|
||||
harness.send("u");
|
||||
|
||||
assert.equal(harness.terminal.kittyProtocolActive, true);
|
||||
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("replays buffered CSI-prefix input when it is not a Kitty response", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[");
|
||||
mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout
|
||||
|
||||
assert.equal(harness.getInput(), undefined);
|
||||
|
||||
mock.timers.tick(150);
|
||||
|
||||
assert.equal(harness.getInput(), "\x1b[");
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal progress", () => {
|
||||
it("writes a valid OSC 9;4 clear sequence", () => {
|
||||
const terminal = new ProcessTerminal();
|
||||
const writes: string[] = [];
|
||||
const previousWrite = process.stdout.write;
|
||||
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
|
||||
try {
|
||||
terminal.setProgress(false);
|
||||
assert.deepEqual(writes, ["\x1b]9;4;0\x07"]);
|
||||
} finally {
|
||||
process.stdout.write = previousWrite;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal dimensions", () => {
|
||||
it("falls back to COLUMNS and LINES before default dimensions", () => {
|
||||
const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
||||
const previousRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
||||
const previousColumns = process.env.COLUMNS;
|
||||
const previousLines = process.env.LINES;
|
||||
|
||||
try {
|
||||
Object.defineProperty(process.stdout, "columns", { value: undefined, configurable: true });
|
||||
Object.defineProperty(process.stdout, "rows", { value: undefined, configurable: true });
|
||||
process.env.COLUMNS = "123";
|
||||
process.env.LINES = "45";
|
||||
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
assert.equal(terminal.columns, 123);
|
||||
assert.equal(terminal.rows, 45);
|
||||
} finally {
|
||||
if (previousColumnsDescriptor) {
|
||||
Object.defineProperty(process.stdout, "columns", previousColumnsDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "columns");
|
||||
}
|
||||
if (previousRowsDescriptor) {
|
||||
Object.defineProperty(process.stdout, "rows", previousRowsDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(process.stdout, "rows");
|
||||
}
|
||||
if (previousColumns === undefined) {
|
||||
delete process.env.COLUMNS;
|
||||
} else {
|
||||
process.env.COLUMNS = previousColumns;
|
||||
}
|
||||
if (previousLines === undefined) {
|
||||
delete process.env.LINES;
|
||||
} else {
|
||||
process.env.LINES = previousLines;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
38
packages/tui/test/test-themes.ts
Normal file
38
packages/tui/test/test-themes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Default themes for TUI tests using chalk
|
||||
*/
|
||||
|
||||
import { Chalk } from "chalk";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.ts";
|
||||
|
||||
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,
|
||||
};
|
||||
127
packages/tui/test/truncate-to-width.test.ts
Normal file
127
packages/tui/test/truncate-to-width.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { normalizeTerminalOutput, truncateToWidth, visibleWidth } from "../src/utils.ts";
|
||||
|
||||
describe("truncateToWidth", () => {
|
||||
it("keeps output within width for very large unicode input", () => {
|
||||
const text = "🙂界".repeat(100_000);
|
||||
const truncated = truncateToWidth(text, 40, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 40);
|
||||
assert.strictEqual(truncated.endsWith("…\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("preserves ANSI styling for kept text and resets before and after ellipsis", () => {
|
||||
const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`;
|
||||
const truncated = truncateToWidth(text, 20, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 20);
|
||||
assert.strictEqual(truncated.includes("\x1b[31m"), true);
|
||||
assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("closes a BEL-terminated OSC 8 link when truncating its label", () => {
|
||||
const open = "\x1b]8;;https://example.com\x07";
|
||||
const close = "\x1b]8;;\x07";
|
||||
const text = `${open}some-longer-label-here${close}`;
|
||||
|
||||
assert.strictEqual(truncateToWidth(text, 15), `${open}some-longer-${close}\x1b[0m...\x1b[0m`);
|
||||
});
|
||||
|
||||
it("handles malformed ANSI escape prefixes without hanging", () => {
|
||||
const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`;
|
||||
const truncated = truncateToWidth(text, 20, "…");
|
||||
|
||||
assert.ok(visibleWidth(truncated) <= 20);
|
||||
});
|
||||
|
||||
it("clips wide ellipsis safely and brackets it with resets", () => {
|
||||
assert.strictEqual(truncateToWidth("abcdef", 1, "🙂"), "");
|
||||
assert.strictEqual(truncateToWidth("abcdef", 2, "🙂"), "\x1b[0m🙂\x1b[0m");
|
||||
assert.ok(visibleWidth(truncateToWidth("abcdef", 2, "🙂")) <= 2);
|
||||
});
|
||||
|
||||
it("returns the original text when it already fits even if ellipsis is too wide", () => {
|
||||
assert.strictEqual(truncateToWidth("a", 2, "🙂"), "a");
|
||||
assert.strictEqual(truncateToWidth("界", 2, "🙂"), "界");
|
||||
});
|
||||
|
||||
it("pads truncated output to requested width", () => {
|
||||
const truncated = truncateToWidth("🙂界🙂界🙂界", 8, "…", true);
|
||||
assert.strictEqual(visibleWidth(truncated), 8);
|
||||
});
|
||||
|
||||
it("adds a trailing reset when truncating without an ellipsis", () => {
|
||||
const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, "");
|
||||
assert.ok(visibleWidth(truncated) <= 10);
|
||||
assert.strictEqual(truncated.endsWith("\x1b[0m"), true);
|
||||
});
|
||||
|
||||
it("keeps a contiguous prefix instead of skipping a wide grapheme and resuming later", () => {
|
||||
const truncated = truncateToWidth("🙂\t界 \x1b_abc\x07", 7, "…", true);
|
||||
assert.strictEqual(truncated, "🙂\t\x1b[0m…\x1b[0m ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleWidth", () => {
|
||||
it("counts tabs inline and skips ANSI inline", () => {
|
||||
assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5);
|
||||
});
|
||||
|
||||
it("counts Indic conjunct spacing code points within grapheme clusters", () => {
|
||||
assert.strictEqual(visibleWidth("र्क"), 2);
|
||||
assert.strictEqual(visibleWidth("नेटवर्क"), 5);
|
||||
assert.strictEqual(visibleWidth("सर्वाधिकार सुरक्षित। ऑर्डर पर क्लिक करें"), 33);
|
||||
assert.strictEqual(visibleWidth("র্ক"), 2);
|
||||
assert.strictEqual(visibleWidth("ર્ક"), 2);
|
||||
assert.strictEqual(visibleWidth("ର୍କ"), 2);
|
||||
assert.strictEqual(visibleWidth("ర్క"), 2);
|
||||
assert.strictEqual(visibleWidth("ര്ക"), 2);
|
||||
});
|
||||
|
||||
it("keeps ordinary combining marks zero-width", () => {
|
||||
assert.strictEqual(visibleWidth("e\u0301"), 1);
|
||||
assert.strictEqual(visibleWidth("čřžůú"), 5);
|
||||
assert.strictEqual(visibleWidth("שָׁ"), 1);
|
||||
assert.strictEqual(visibleWidth("بّ"), 1);
|
||||
assert.strictEqual(visibleWidth("རྐ"), 1);
|
||||
assert.strictEqual(visibleWidth("ᜠ᜴"), 1);
|
||||
assert.strictEqual(visibleWidth("가〮"), 2);
|
||||
assert.strictEqual(visibleWidth("가〯"), 2);
|
||||
});
|
||||
|
||||
it("keeps CJK and Japanese width accounting unchanged", () => {
|
||||
assert.strictEqual(visibleWidth("网络"), 4);
|
||||
assert.strictEqual(visibleWidth("ネットワーク"), 12);
|
||||
assert.strictEqual(visibleWidth("が"), 2);
|
||||
assert.strictEqual(visibleWidth("か\u3099"), 2);
|
||||
});
|
||||
|
||||
it("counts Myanmar marks that terminals allocate cells for", () => {
|
||||
assert.strictEqual(visibleWidth("ကာ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကေ"), 2);
|
||||
assert.strictEqual(visibleWidth("က်"), 2);
|
||||
assert.strictEqual(visibleWidth("ကျ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကြ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဳ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဴ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကဵ"), 2);
|
||||
assert.strictEqual(visibleWidth("ကး"), 2);
|
||||
assert.strictEqual(visibleWidth("ကို"), 1);
|
||||
assert.strictEqual(visibleWidth("က္"), 1);
|
||||
});
|
||||
|
||||
it("keeps Thai and Lao AM clusters at their normal cell width", () => {
|
||||
assert.strictEqual(visibleWidth("ำ"), 1);
|
||||
assert.strictEqual(visibleWidth("ຳ"), 1);
|
||||
assert.strictEqual(visibleWidth("กำ"), 2);
|
||||
assert.strictEqual(visibleWidth("ກຳ"), 2);
|
||||
});
|
||||
|
||||
it("normalizes Thai and Lao AM vowels only for terminal output", () => {
|
||||
assert.strictEqual(normalizeTerminalOutput("ำ"), "ํา");
|
||||
assert.strictEqual(normalizeTerminalOutput("ຳ"), "ໍາ");
|
||||
assert.strictEqual(visibleWidth(normalizeTerminalOutput("ำabc")), visibleWidth("ำabc"));
|
||||
assert.strictEqual(visibleWidth(normalizeTerminalOutput("ຳabc")), visibleWidth("ຳabc"));
|
||||
});
|
||||
});
|
||||
129
packages/tui/test/truncated-text.test.ts
Normal file
129
packages/tui/test/truncated-text.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Chalk } from "chalk";
|
||||
import { TruncatedText } from "../src/components/truncated-text.ts";
|
||||
import { visibleWidth } from "../src/utils.ts";
|
||||
|
||||
// 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"));
|
||||
});
|
||||
});
|
||||
1908
packages/tui/test/tui-alt-screen.test.ts
Normal file
1908
packages/tui/test/tui-alt-screen.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
82
packages/tui/test/tui-cell-size-input.test.ts
Normal file
82
packages/tui/test/tui-cell-size-input.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class InputRecorder implements Component {
|
||||
readonly inputs: string[] = [];
|
||||
|
||||
render(): string[] {
|
||||
return [""];
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.inputs.push(data);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function withImageTerminal<T>(fn: () => T): T {
|
||||
const prevTermProgram = process.env.TERM_PROGRAM;
|
||||
const prevTerm = process.env.TERM;
|
||||
const prevGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR;
|
||||
|
||||
process.env.TERM_PROGRAM = "ghostty";
|
||||
delete process.env.TERM;
|
||||
delete process.env.GHOSTTY_RESOURCES_DIR;
|
||||
resetCapabilitiesCache();
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prevTermProgram === undefined) delete process.env.TERM_PROGRAM;
|
||||
else process.env.TERM_PROGRAM = prevTermProgram;
|
||||
if (prevTerm === undefined) delete process.env.TERM;
|
||||
else process.env.TERM = prevTerm;
|
||||
if (prevGhosttyResourcesDir === undefined) delete process.env.GHOSTTY_RESOURCES_DIR;
|
||||
else process.env.GHOSTTY_RESOURCES_DIR = prevGhosttyResourcesDir;
|
||||
resetCapabilitiesCache();
|
||||
}
|
||||
}
|
||||
|
||||
describe("TUI cell size responses", () => {
|
||||
it("forwards bare escape even when a cell size query was sent at startup", () => {
|
||||
withImageTerminal(() => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const recorder = new InputRecorder();
|
||||
|
||||
tui.setFocus(recorder);
|
||||
tui.start();
|
||||
|
||||
terminal.sendInput("\x1b");
|
||||
|
||||
assert.deepStrictEqual(recorder.inputs, ["\x1b"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes cell size responses and still forwards later user input", () => {
|
||||
withImageTerminal(() => {
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const recorder = new InputRecorder();
|
||||
|
||||
tui.setFocus(recorder);
|
||||
tui.start();
|
||||
|
||||
terminal.sendInput("\x1b[6;20;10t");
|
||||
assert.deepStrictEqual(recorder.inputs, []);
|
||||
assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 });
|
||||
|
||||
terminal.sendInput("q");
|
||||
assert.deepStrictEqual(recorder.inputs, ["q"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/tui/test/tui-overlay-style-leak.test.ts
Normal file
81
packages/tui/test/tui-overlay-style-leak.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class StaticLines implements Component {
|
||||
private readonly lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
private readonly line: string;
|
||||
|
||||
constructor(line: string) {
|
||||
this.line = line;
|
||||
}
|
||||
|
||||
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.waitForRender();
|
||||
}
|
||||
|
||||
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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(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();
|
||||
});
|
||||
});
|
||||
952
packages/tui/test/tui-render.test.ts
Normal file
952
packages/tui/test/tui-render.test.ts
Normal file
@@ -0,0 +1,952 @@
|
||||
import assert from "node:assert";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Image } from "../src/components/image.ts";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
import {
|
||||
deleteKittyImage,
|
||||
encodeKitty,
|
||||
resetCapabilitiesCache,
|
||||
setCapabilities,
|
||||
setCellDimensions,
|
||||
} from "../src/terminal-image.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class TestComponent implements Component {
|
||||
lines: string[] = [];
|
||||
render(_width: number): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class InputComponent extends TestComponent {
|
||||
renderCount = 0;
|
||||
|
||||
override render(width: number): string[] {
|
||||
this.renderCount += 1;
|
||||
return super.render(width);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.lines = [data];
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
||||
|
||||
class BoundedWriteTerminal implements Terminal {
|
||||
readonly writes: string[] = [];
|
||||
columns = 80;
|
||||
rows = 24;
|
||||
readonly kittyProtocolActive = false;
|
||||
|
||||
start(_onInput: (data: string) => void, _onResize: () => void): void {}
|
||||
stop(): void {}
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(_lines: number): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
}
|
||||
|
||||
class LoggingVirtualTerminal extends VirtualTerminal {
|
||||
private writes: string[] = [];
|
||||
|
||||
override write(data: string): void {
|
||||
this.writes.push(data);
|
||||
super.write(data);
|
||||
}
|
||||
|
||||
getWrites(): string {
|
||||
return this.writes.join("");
|
||||
}
|
||||
|
||||
clearWrites(): void {
|
||||
this.writes = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function withEnv<T>(updates: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
|
||||
const previousValues = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
previousValues.set(key, process.env[key]);
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
for (const [key, value] of previousValues) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 render scheduling", () => {
|
||||
it("renders keyboard input without waiting for a throttled frame", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new InputComponent();
|
||||
component.lines = ["initial"];
|
||||
tui.addChild(component);
|
||||
tui.setFocus(component);
|
||||
tui.start();
|
||||
tui.renderNow();
|
||||
const renderCountBeforeInput = component.renderCount;
|
||||
|
||||
// Queue a normal throttled render first. Keyboard input should preempt it.
|
||||
component.lines = ["pending"];
|
||||
tui.requestRender();
|
||||
terminal.sendInput("first");
|
||||
terminal.sendInput("second");
|
||||
terminal.sendInput("typed");
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
|
||||
assert.strictEqual(component.renderCount, renderCountBeforeInput + 1);
|
||||
assert.deepStrictEqual(component.lines, ["typed"]);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI debug logging", () => {
|
||||
it("writes redraw logs to the provided directory", async () => {
|
||||
const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-"));
|
||||
try {
|
||||
await withEnv({ PI_TUI_DEBUG_REDRAW: "1" }, async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal, undefined, logDir);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["test"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.match(readFileSync(join(logDir, "pi-tui-debug.log"), "utf-8"), /fullRender: first render/);
|
||||
tui.stop();
|
||||
});
|
||||
} finally {
|
||||
rmSync(logDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI bounded render output", () => {
|
||||
it("splits a large full render without changing its output", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = [kittyLine, kittyLine];
|
||||
tui.addChild(component);
|
||||
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(
|
||||
terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS),
|
||||
"each terminal write should stay below the configured limit",
|
||||
);
|
||||
assert.strictEqual(
|
||||
terminal.writes.join(""),
|
||||
`\x1b[?2026h${kittyLine}\r\n${kittyLine}\x1b[?2026l`,
|
||||
"chunking must preserve the synchronized render output",
|
||||
);
|
||||
});
|
||||
|
||||
it("splits large differential updates without a full redraw", () => {
|
||||
const terminal = new BoundedWriteTerminal();
|
||||
const tui = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["before"];
|
||||
tui.renderNow();
|
||||
terminal.writes.length = 0;
|
||||
|
||||
const kittyLine = `\x1b_Ga=T,f=100;${"A".repeat(1_200_000)}\x1b\\`;
|
||||
component.lines = ["before", kittyLine, kittyLine];
|
||||
tui.renderNow();
|
||||
|
||||
assert.ok(terminal.writes.length > 2, "large output should be split across terminal writes");
|
||||
assert.ok(terminal.writes.every((write) => write.length <= MAX_RENDER_WRITE_CHARS));
|
||||
const output = terminal.writes.join("");
|
||||
assert.ok(output.startsWith("\x1b[?2026h"));
|
||||
assert.ok(output.endsWith("\x1b[?2026l"));
|
||||
assert.ok(!output.includes("\x1b[2J"), "the update should stay on the differential render path");
|
||||
});
|
||||
});
|
||||
|
||||
/** Set each environment variable to `value`, returning a function that restores the previous state. */
|
||||
function overrideEnv(names: readonly string[], value: string): () => void {
|
||||
const previousValues = names.map((name) => [name, process.env[name]] as const);
|
||||
for (const name of names) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
for (const [name, previousValue] of previousValues) {
|
||||
if (previousValue === undefined) delete process.env[name];
|
||||
else process.env[name] = previousValue;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("TUI crash dump without configured log directory", () => {
|
||||
it("writes the crash dump to the OS temp directory instead of a home-directory default", async () => {
|
||||
// The TUI falls back to os.tmpdir() when no log directory is configured, so
|
||||
// isolate the test by pointing the temp directory at a fresh directory rather
|
||||
// than sharing the real one with concurrent test runs. os.tmpdir() reads
|
||||
// TMPDIR on POSIX and TEMP/TMP on Windows, so override all three.
|
||||
const crashDir = mkdtempSync(join(tmpdir(), "pi-tui-crash-"));
|
||||
const crashLogPath = join(crashDir, "pi-tui-crash.log");
|
||||
const restoreTmpdirEnv = overrideEnv(["TMPDIR", "TEMP", "TMP"], crashDir);
|
||||
try {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["ok"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Width overflow is detected in the differential render path
|
||||
component.lines = ["ok", "x".repeat(60)];
|
||||
assert.throws(
|
||||
() => tui.renderNow(),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error.message.includes(crashLogPath), `error message should reference ${crashLogPath}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.match(readFileSync(crashLogPath, "utf-8"), /Terminal width: 40/);
|
||||
} finally {
|
||||
restoreTmpdirEnv();
|
||||
rmSync(crashDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI Kitty image cleanup", () => {
|
||||
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 2 },
|
||||
{ widthPx: 20, heightPx: 20 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
component.lines = ["before", ...imageLines, "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(
|
||||
writes.includes(`\x1b[2K\r\n\x1b[2K\x1b[1A${imageSequence}\x1b[1B`),
|
||||
"reserved rows should be cleared before the image placement is drawn",
|
||||
);
|
||||
assert.ok(
|
||||
!writes.includes(`${imageSequence}\r\n\x1b[2K`),
|
||||
"reserved row clears must not run after the image placement is drawn",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to full redraw when Kitty image pre-clear would scroll", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 2);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const redrawsBeforeImage = tui.fullRedraws;
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 3 },
|
||||
{ widthPx: 30, heightPx: 30 },
|
||||
);
|
||||
component.lines = ["before", ...image.render(40), "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeImage, "unsafe image pre-clear should force a full redraw");
|
||||
assert.ok(terminal.getWrites().includes("\x1b[2J"), "fallback should clear and fully redraw");
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves Kitty image rows before drawing during full redraw fallbacks", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["l0", "l1", "l2", "l3", "l4"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
const redrawsBeforeImage = tui.fullRedraws;
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 3 },
|
||||
{ widthPx: 30, heightPx: 30 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
component.lines = ["l0", "l1", "l2", "l3", "l4", ...imageLines, "after"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeImage, "scrolling image append should force a full redraw");
|
||||
assert.ok(
|
||||
writes.includes(`\r\n\r\n\x1b[2A${imageSequence}\x1b[2B`),
|
||||
"full redraw should reserve visible image rows before drawing the placement",
|
||||
);
|
||||
assert.ok(
|
||||
!writes.includes(`${imageSequence}\r\n\x1b[0m`),
|
||||
"full redraw must not write reserved padding rows after drawing the placement",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not use cursor-up placement for Kitty images taller than the viewport", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
setCellDimensions({ widthPx: 10, heightPx: 10 });
|
||||
try {
|
||||
const terminal = new LoggingVirtualTerminal(40, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["before"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const image = new Image(
|
||||
"AAAA",
|
||||
"image/png",
|
||||
{ fallbackColor: (value) => value },
|
||||
{ maxWidthCells: 6 },
|
||||
{ widthPx: 60, heightPx: 60 },
|
||||
);
|
||||
const imageLines = image.render(40);
|
||||
const imageSequence = imageLines[0];
|
||||
assert.ok(imageLines.length > terminal.rows, "test image should exceed the viewport height");
|
||||
|
||||
component.lines = ["before", ...imageLines, "after"];
|
||||
tui.requestRender(true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(writes.includes(imageSequence), "image placement should be drawn");
|
||||
assert.ok(
|
||||
!writes.includes(`\x1b[${imageLines.length - 1}A${imageSequence}`),
|
||||
"taller-than-viewport images must keep the #4461 first-row placement path",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
} finally {
|
||||
resetCapabilitiesCache();
|
||||
setCellDimensions({ widthPx: 9, heightPx: 18 });
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes changed image ids before drawing moved placements", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
const oldImage = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 42, moveCursor: false });
|
||||
component.lines = ["top", oldImage];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const newImage = encodeKitty("BBBB", { columns: 2, rows: 1, imageId: 42, moveCursor: false });
|
||||
component.lines = [newImage, ""];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(42));
|
||||
const drawIndex = writes.indexOf(newImage);
|
||||
assert.ok(deleteIndex >= 0, "changed old image should be deleted");
|
||||
assert.ok(drawIndex >= 0, "new image should be drawn");
|
||||
assert.ok(deleteIndex < drawIndex, "old image must be deleted before the new placement is drawn");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("redraws image lines when an earlier reserved image row changes", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
const image = encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 88, moveCursor: false });
|
||||
component.lines = ["", image];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
component.lines = ["covered", image];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(88));
|
||||
const drawIndex = writes.indexOf(image);
|
||||
assert.ok(deleteIndex >= 0, "image should be deleted when a reserved row changes");
|
||||
assert.ok(drawIndex >= 0, "unchanged image line should be redrawn after deleting the placement");
|
||||
assert.ok(deleteIndex < drawIndex, "old placement must be deleted before the image line is redrawn");
|
||||
assert.ok(!writes.includes("\x1b[2J"), "reserved row changes should not force a full redraw");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("deletes previously rendered image ids during full redraws", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = [encodeKitty("AAAA", { columns: 2, rows: 2, imageId: 77, moveCursor: false })];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
component.lines = ["plain text"];
|
||||
tui.requestRender(true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
const deleteIndex = writes.indexOf(deleteKittyImage(77));
|
||||
const clearIndex = writes.indexOf("\x1b[2J");
|
||||
assert.ok(deleteIndex >= 0, "previous image should be deleted during full redraw");
|
||||
assert.ok(clearIndex >= 0, "full redraw should clear the screen");
|
||||
assert.ok(deleteIndex < clearIndex, "old image should be deleted before the screen is cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI resize handling", () => {
|
||||
it("triggers full re-render when terminal height changes", async () => {
|
||||
await withEnv({ TERMUX_VERSION: undefined }, async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize height
|
||||
terminal.resize(40, 15);
|
||||
await terminal.waitForRender();
|
||||
|
||||
// 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("skips full re-render on height changes in Termux", async () => {
|
||||
await withEnv({ TERMUX_VERSION: "1" }, async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
for (const height of [15, 8, 14, 11]) {
|
||||
terminal.resize(40, height);
|
||||
await terminal.waitForRender();
|
||||
}
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw");
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Height change should not clear the screen");
|
||||
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Height change should not clear scrollback");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.join("\n").includes("Line 19"), "Latest content remains visible after resize");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
it("triggers full re-render when terminal width changes", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize width
|
||||
terminal.resize(60, 10);
|
||||
await terminal.waitForRender();
|
||||
|
||||
// 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: TUI = new TuiMainScreen(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.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Shrink to fewer lines
|
||||
component.lines = ["Line 0", "Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// 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: TUI = new TuiMainScreen(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.waitForRender();
|
||||
|
||||
// Shrink to single line
|
||||
component.lines = ["Only line"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(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.waitForRender();
|
||||
|
||||
// Shrink to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(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.waitForRender();
|
||||
|
||||
// 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.waitForRender();
|
||||
|
||||
// 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.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Initial render
|
||||
component.lines = ["Header", "Working...", "Footer"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Simulate spinner animation - only middle line changes
|
||||
const spinnerFrames = ["|", "/", "-", "\\"];
|
||||
for (const frame of spinnerFrames) {
|
||||
component.lines = ["Header", `Working ${frame}`, "Footer"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["\x1b[3mItalic", "Plain"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Change only first line
|
||||
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Change only last line
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// 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.waitForRender();
|
||||
|
||||
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: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Start with content
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
let viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
|
||||
|
||||
// Clear to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Add content back - this should work correctly even after empty state
|
||||
component.lines = ["New Line 0", "New Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("full re-renders when deleted lines move the viewport upward", async () => {
|
||||
const terminal = new VirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("appends after a shrink without another full redraw once the viewport is reset", async () => {
|
||||
const terminal = new VirtualTerminal(20, 5);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
component.lines = ["Line 0", "Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
|
||||
const redrawsAfterShrink = tui.fullRedraws;
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("clears stale content when maxLinesRendered was inflated by a transient component", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const chat = new TestComponent();
|
||||
const editor = new TestComponent();
|
||||
tui.addChild(chat);
|
||||
tui.addChild(editor);
|
||||
|
||||
const longChat = Array.from({ length: 15 }, (_, i) => `Chat ${i}`);
|
||||
const shortChat = Array.from({ length: 12 }, (_, i) => `Chat ${i}`);
|
||||
const editorLines = ["Editor 0", "Editor 1", "Editor 2"];
|
||||
const selectorLines = Array.from({ length: 8 }, (_, i) => `Selector ${i}`);
|
||||
|
||||
chat.lines = longChat;
|
||||
editor.lines = editorLines;
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
editor.lines = selectorLines;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
editor.lines = editorLines;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const redrawsBeforeSwitch = tui.fullRedraws;
|
||||
chat.lines = shortChat;
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(tui.fullRedraws > redrawsBeforeSwitch, "Branch switch should trigger a full redraw");
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const line = viewport[i] ?? "";
|
||||
assert.ok(!line.includes("Chat 12"), `Stale "Chat 12" at viewport row ${i}`);
|
||||
assert.ok(!line.includes("Chat 13"), `Stale "Chat 13" at viewport row ${i}`);
|
||||
assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`);
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(viewport, [
|
||||
"Chat 5",
|
||||
"Chat 6",
|
||||
"Chat 7",
|
||||
"Chat 8",
|
||||
"Chat 9",
|
||||
"Chat 10",
|
||||
"Chat 11",
|
||||
"Editor 0",
|
||||
"Editor 1",
|
||||
"Editor 2",
|
||||
]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
45
packages/tui/test/tui-shrink.test.ts
Normal file
45
packages/tui/test/tui-shrink.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class Lines implements Component {
|
||||
private lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
describe("TUI shrinking content", () => {
|
||||
it("clears all rendered lines when content shrinks to zero", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const content = new Lines(["first", "second", "third"]);
|
||||
tui.addChild(content);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("first")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("second")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("third")));
|
||||
|
||||
tui.clear();
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
108
packages/tui/test/viewport-overwrite-repro.ts
Normal file
108
packages/tui/test/viewport-overwrite-repro.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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.ts";
|
||||
import type { Component, TUI } from "../src/tui.ts";
|
||||
import { TuiMainScreen } from "../src/tui-main-screen.ts";
|
||||
|
||||
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: TUI = new TuiMainScreen(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: TUI = new TuiMainScreen(new ProcessTerminal());
|
||||
ui.stop();
|
||||
} catch {
|
||||
// Ignore restore errors.
|
||||
}
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
218
packages/tui/test/virtual-terminal.ts
Normal file
218
packages/tui/test/virtual-terminal.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import xterm from "@xterm/headless";
|
||||
import type { Terminal } from "../src/terminal.ts";
|
||||
|
||||
// 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`);
|
||||
}
|
||||
|
||||
setProgress(_active: boolean): void {}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wait for TUI's throttled render pipeline to settle. */
|
||||
async waitForRender(): Promise<void> {
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
191
packages/tui/test/word-navigation.test.ts
Normal file
191
packages/tui/test/word-navigation.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { findWordBackward, findWordForward } from "../src/word-navigation.ts";
|
||||
|
||||
describe("findWordBackward", () => {
|
||||
it("basic words: hello world", () => {
|
||||
const text = "hello world";
|
||||
assert.strictEqual(findWordBackward(text, 11), 6);
|
||||
assert.strictEqual(findWordBackward(text, 6), 0);
|
||||
});
|
||||
|
||||
it("dotted: foo.bar", () => {
|
||||
const text = "foo.bar";
|
||||
assert.strictEqual(findWordBackward(text, 7), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("colon: foo:bar", () => {
|
||||
const text = "foo:bar";
|
||||
assert.strictEqual(findWordBackward(text, 7), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("path: path/to/file", () => {
|
||||
const text = "path/to/file";
|
||||
assert.strictEqual(findWordBackward(text, 12), 8);
|
||||
assert.strictEqual(findWordBackward(text, 8), 7);
|
||||
// "/to" is one word-like segment with "/" as punctuation boundary
|
||||
assert.strictEqual(findWordBackward(text, 7), 5);
|
||||
assert.strictEqual(findWordBackward(text, 5), 4);
|
||||
assert.strictEqual(findWordBackward(text, 4), 0);
|
||||
});
|
||||
|
||||
it("CJK mixed", () => {
|
||||
const text = "你好世界 test";
|
||||
assert.strictEqual(findWordBackward(text, text.length), 5);
|
||||
// Intl.Segmenter treats each CJK char as a separate word-like segment
|
||||
assert.strictEqual(findWordBackward(text, 5), 2);
|
||||
assert.strictEqual(findWordBackward(text, 2), 0);
|
||||
});
|
||||
|
||||
it("whitespace at boundaries", () => {
|
||||
const text = " hello ";
|
||||
assert.strictEqual(findWordBackward(text, 9), 2);
|
||||
assert.strictEqual(findWordBackward(text, 2), 0);
|
||||
});
|
||||
|
||||
it("punctuation run: foo...bar", () => {
|
||||
const text = "foo...bar";
|
||||
assert.strictEqual(findWordBackward(text, 9), 6);
|
||||
assert.strictEqual(findWordBackward(text, 6), 3);
|
||||
assert.strictEqual(findWordBackward(text, 3), 0);
|
||||
});
|
||||
|
||||
it("cursor at 0 returns 0", () => {
|
||||
assert.strictEqual(findWordBackward("hello", 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findWordForward", () => {
|
||||
it("basic words: hello world", () => {
|
||||
const text = "hello world";
|
||||
assert.strictEqual(findWordForward(text, 0), 5);
|
||||
assert.strictEqual(findWordForward(text, 5), 11);
|
||||
});
|
||||
|
||||
it("dotted: foo.bar", () => {
|
||||
const text = "foo.bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 7);
|
||||
});
|
||||
|
||||
it("colon: foo:bar", () => {
|
||||
const text = "foo:bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 7);
|
||||
});
|
||||
|
||||
it("path: path/to/file", () => {
|
||||
const text = "path/to/file";
|
||||
assert.strictEqual(findWordForward(text, 0), 4);
|
||||
assert.strictEqual(findWordForward(text, 4), 5);
|
||||
assert.strictEqual(findWordForward(text, 5), 7);
|
||||
assert.strictEqual(findWordForward(text, 7), 8);
|
||||
assert.strictEqual(findWordForward(text, 8), 12);
|
||||
});
|
||||
|
||||
it("CJK mixed", () => {
|
||||
const text = "你好世界 test";
|
||||
const firstEnd = findWordForward(text, 0);
|
||||
assert.ok(firstEnd > 0);
|
||||
assert.ok(firstEnd <= 4);
|
||||
// Walk to end
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const next = findWordForward(text, pos);
|
||||
if (next === pos) break;
|
||||
pos = next;
|
||||
}
|
||||
assert.strictEqual(pos, text.length);
|
||||
});
|
||||
|
||||
it("whitespace at boundaries", () => {
|
||||
const text = " hello ";
|
||||
assert.strictEqual(findWordForward(text, 0), 7);
|
||||
assert.strictEqual(findWordForward(text, 7), 9);
|
||||
});
|
||||
|
||||
it("punctuation run: foo...bar", () => {
|
||||
const text = "foo...bar";
|
||||
assert.strictEqual(findWordForward(text, 0), 3);
|
||||
assert.strictEqual(findWordForward(text, 3), 6);
|
||||
assert.strictEqual(findWordForward(text, 6), 9);
|
||||
});
|
||||
|
||||
it("cursor at end returns end", () => {
|
||||
assert.strictEqual(findWordForward("hello", 5), 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomic segments", () => {
|
||||
const marker = "[paste #1 +5 lines]";
|
||||
const text = `hello ${marker} world`;
|
||||
const isAtomic = (s: string) => s === marker;
|
||||
|
||||
// The functions slice text before calling segment(), so we map each expected
|
||||
// substring to its pre-split segments.
|
||||
const segmentMap = new Map<string, Intl.SegmentData[]>([
|
||||
[
|
||||
text, // full text (not used but for clarity)
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 26, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
[
|
||||
// backward from end: slice(0, 31) = full text
|
||||
text.slice(0, text.length),
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 26, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
[
|
||||
// backward from 26: slice(0, 26) = "hello [paste #1 +5 lines] "
|
||||
text.slice(0, 26),
|
||||
[
|
||||
{ segment: "hello", index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 5, input: text, isWordLike: false },
|
||||
{ segment: marker, index: 6, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 25, input: text, isWordLike: false },
|
||||
],
|
||||
],
|
||||
[
|
||||
// forward from 6: slice(6) = "[paste #1 +5 lines] world"
|
||||
text.slice(6),
|
||||
[
|
||||
{ segment: marker, index: 0, input: text, isWordLike: true },
|
||||
{ segment: " ", index: 19, input: text, isWordLike: false },
|
||||
{ segment: "world", index: 20, input: text, isWordLike: true },
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const opts = {
|
||||
segment: (input: string) => segmentMap.get(input) ?? [],
|
||||
isAtomicSegment: isAtomic,
|
||||
};
|
||||
|
||||
it("backward skips word then stops before atomic marker", () => {
|
||||
assert.strictEqual(findWordBackward(text, text.length, opts), 26);
|
||||
});
|
||||
|
||||
it("backward skips whitespace then atomic marker as one unit", () => {
|
||||
assert.strictEqual(findWordBackward(text, 26, opts), 6);
|
||||
});
|
||||
|
||||
it("forward skips atomic marker as one unit", () => {
|
||||
assert.strictEqual(findWordForward(text, 6, opts), 6 + marker.length);
|
||||
});
|
||||
});
|
||||
266
packages/tui/test/wrap-ansi.test.ts
Normal file
266
packages/tui/test/wrap-ansi.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.ts";
|
||||
|
||||
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 handle LF, CRLF, and CR line endings", () => {
|
||||
assert.deepStrictEqual(wrapTextWithAnsi("first\nsecond\r\nthird\rfourth", 80), [
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
"fourth",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve ANSI state across CRLF and CR line endings", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
assert.deepStrictEqual(wrapTextWithAnsi(`${red}first\r\nsecond\rthird${reset}`, 80), [
|
||||
`${red}first`,
|
||||
`${red}second`,
|
||||
`${red}third${reset}`,
|
||||
]);
|
||||
});
|
||||
|
||||
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 break CJK runs at grapheme boundaries after Latin text", () => {
|
||||
const text = "This is an example 中文汉字测试段落内容中文汉字测试段落内容.";
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
assert.deepStrictEqual(wrapped, ["This is an example 中文汉字测试段落内容", "中文汉字测试段落内容."]);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve color codes when wrapping CJK runs", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${red}This is an example 中文汉字测试段落内容中文汉字测试段落内容.${reset}`;
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
assert.strictEqual(wrapped.length, 2);
|
||||
assert.strictEqual(wrapped[0], `${red}This is an example 中文汉字测试段落内容`);
|
||||
assert.strictEqual(wrapped[1], `${red}中文汉字测试段落内容.${reset}`);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 40);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
|
||||
it("re-emits OSC 8 open at the start of continuation lines", () => {
|
||||
// A hyperlink whose text is long enough to wrap
|
||||
const url = "https://example.com";
|
||||
// OSC 8 open + text that is 10 visible chars + OSC 8 close
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
// Every line that contains visible text from inside the hyperlink
|
||||
// should start with the OSC 8 open sequence (or be preceded by it).
|
||||
for (const line of lines) {
|
||||
// If the line has visible content it must begin with the OSC 8 re-open
|
||||
// OR it is the line where the close appeared with no following content.
|
||||
const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
if (stripped.trim().length > 0) {
|
||||
assert.ok(
|
||||
line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`),
|
||||
`Line "${line}" has visible text but no OSC 8 re-open`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("closes OSC 8 before each line break", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
|
||||
const lines = wrapTextWithAnsi(input, 6);
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const line = lines[i];
|
||||
// Every non-final line that is inside a hyperlink should end with the close
|
||||
if (line.includes(`\x1b]8;;${url}\x1b\\`)) {
|
||||
assert.ok(
|
||||
line.endsWith("\x1b]8;;\x1b\\"),
|
||||
`Non-final line "${line}" is inside a hyperlink but does not close it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => {
|
||||
const url = `https://example.com/oauth/${"a".repeat(32)}`;
|
||||
const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
|
||||
const lines = wrapTextWithAnsi(input, 20);
|
||||
|
||||
assert.ok(lines.length > 1);
|
||||
for (const line of lines) {
|
||||
assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`);
|
||||
assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`);
|
||||
}
|
||||
for (const line of lines.slice(0, -1)) {
|
||||
assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
|
||||
const url = "https://example.com";
|
||||
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
||||
const lines = wrapTextWithAnsi(input, 80);
|
||||
|
||||
// With width 80 everything fits on one line; there should be exactly one
|
||||
// OSC 8 open and one OSC 8 close.
|
||||
assert.strictEqual(lines.length, 1);
|
||||
const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length;
|
||||
const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length;
|
||||
assert.strictEqual(openCount, 1);
|
||||
assert.strictEqual(closeCount, 1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user