mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-17 06:04:52 +00:00
move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
521
packages/tui/test/autocomplete.test.ts
Normal file
521
packages/tui/test/autocomplete.test.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import assert from "node:assert";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, it, test } from "node:test";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
|
||||
|
||||
const resolveFdPath = (): string | null => {
|
||||
const command = process.platform === "win32" ? "where" : "which";
|
||||
const result = spawnSync(command, ["fd"], { encoding: "utf-8" });
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
|
||||
return firstLine ? firstLine.trim() : null;
|
||||
};
|
||||
|
||||
type FolderStructure = {
|
||||
dirs?: string[];
|
||||
files?: Record<string, string>;
|
||||
};
|
||||
|
||||
const setupFolder = (
|
||||
baseDir: string,
|
||||
structure: FolderStructure = {},
|
||||
): void => {
|
||||
const dirs = structure.dirs ?? [];
|
||||
const files = structure.files ?? {};
|
||||
|
||||
dirs.forEach((dir) => {
|
||||
mkdirSync(join(baseDir, dir), { recursive: true });
|
||||
});
|
||||
Object.entries(files).forEach(([filePath, contents]) => {
|
||||
const fullPath = join(baseDir, filePath);
|
||||
mkdirSync(dirname(fullPath), { recursive: true });
|
||||
writeFileSync(fullPath, contents);
|
||||
});
|
||||
};
|
||||
|
||||
const fdPath = resolveFdPath();
|
||||
const isFdInstalled = Boolean(fdPath);
|
||||
|
||||
const requireFdPath = (): string => {
|
||||
if (!fdPath) {
|
||||
throw new Error("fd is not available");
|
||||
}
|
||||
return fdPath;
|
||||
};
|
||||
|
||||
describe("CombinedAutocompleteProvider", () => {
|
||||
describe("extractPathPrefix", () => {
|
||||
it("extracts / from 'hey /' when forced", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["hey /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 5; // After the "/"
|
||||
|
||||
const result = provider.getForceFileSuggestions(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for root directory",
|
||||
);
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts /A from '/A' when forced", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/A"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 2; // After the "A"
|
||||
|
||||
const result = provider.getForceFileSuggestions(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
);
|
||||
|
||||
console.log("Result:", result);
|
||||
// This might return null if /A doesn't match anything, which is fine
|
||||
// We're mainly testing that the prefix extraction works
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not trigger for slash commands", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/model"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 6; // After "model"
|
||||
|
||||
const result = provider.getForceFileSuggestions(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.strictEqual(result, null, "Should not trigger for slash commands");
|
||||
});
|
||||
|
||||
it("triggers for absolute paths after slash command argument", () => {
|
||||
const provider = new CombinedAutocompleteProvider([], "/tmp");
|
||||
const lines = ["/command /"];
|
||||
const cursorLine = 0;
|
||||
const cursorCol = 10; // After the second "/"
|
||||
|
||||
const result = provider.getForceFileSuggestions(
|
||||
lines,
|
||||
cursorLine,
|
||||
cursorCol,
|
||||
);
|
||||
|
||||
console.log("Result:", result);
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should trigger for absolute paths in command arguments",
|
||||
);
|
||||
if (result) {
|
||||
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
|
||||
let rootDir = "";
|
||||
let baseDir = "";
|
||||
let outsideDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-"));
|
||||
baseDir = join(rootDir, "cwd");
|
||||
outsideDir = join(rootDir, "outside");
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
mkdirSync(outsideDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns all files and folders for empty @ query", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value).sort();
|
||||
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
|
||||
});
|
||||
|
||||
test("matches file with extension in query", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"file.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@file.txt";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@file.txt"));
|
||||
});
|
||||
|
||||
test("filters are case insensitive", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"README.md": "readme",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@re";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value).sort();
|
||||
assert.deepStrictEqual(values, ["@README.md"]);
|
||||
});
|
||||
|
||||
test("ranks directories before files", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["src"],
|
||||
files: {
|
||||
"src.txt": "text",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@src";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const firstValue = result?.items[0]?.value;
|
||||
const hasSrcFile = result?.items?.some(
|
||||
(item) => item.value === "@src.txt",
|
||||
);
|
||||
assert.strictEqual(firstValue, "@src/");
|
||||
assert.ok(hasSrcFile);
|
||||
});
|
||||
|
||||
test("returns nested file paths", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/index.ts": "export {};\n",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@index";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@src/index.ts"));
|
||||
});
|
||||
|
||||
test("matches deeply nested paths", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"packages/tui/src/autocomplete.ts": "export {};",
|
||||
"packages/ai/src/autocomplete.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@tui/src/auto";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
|
||||
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
|
||||
});
|
||||
|
||||
test("matches directory in middle of path with --full-path", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"src/components/Button.tsx": "export {};",
|
||||
"src/utils/helpers.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@components/";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@src/components/Button.tsx"));
|
||||
assert.ok(!values?.includes("@src/utils/helpers.ts"));
|
||||
});
|
||||
|
||||
test("scopes fuzzy search to relative directories and searches recursively", () => {
|
||||
setupFolder(outsideDir, {
|
||||
files: {
|
||||
"nested/alpha.ts": "export {};",
|
||||
"nested/deeper/also-alpha.ts": "export {};",
|
||||
"nested/deeper/zzz.ts": "export {};",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@../outside/a";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
|
||||
assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts"));
|
||||
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
|
||||
});
|
||||
|
||||
test("quotes paths with spaces for @ suggestions", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@my";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('@"my folder/"'));
|
||||
});
|
||||
|
||||
test("includes hidden paths but excludes .git", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: [".pi", ".github", ".git"],
|
||||
files: {
|
||||
".pi/config.json": "{}",
|
||||
".github/workflows/ci.yml": "name: ci",
|
||||
".git/config": "[core]",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = "@";
|
||||
const result = provider.getSuggestions([line], 0, line.length);
|
||||
|
||||
const values = result?.items.map((item) => item.value) ?? [];
|
||||
assert.ok(values.includes("@.pi/"));
|
||||
assert.ok(values.includes("@.github/"));
|
||||
assert.ok(
|
||||
!values.some(
|
||||
(value) => value === "@.git" || value.startsWith("@.git/"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("continues autocomplete inside quoted @ paths", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
"my folder/other.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = '@"my folder/"';
|
||||
const result = provider.getSuggestions([line], 0, line.length - 1);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for quoted folder path",
|
||||
);
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('@"my folder/test.txt"'));
|
||||
assert.ok(values?.includes('@"my folder/other.txt"'));
|
||||
});
|
||||
|
||||
test("applies quoted @ completion without duplicating closing quote", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider(
|
||||
[],
|
||||
baseDir,
|
||||
requireFdPath(),
|
||||
);
|
||||
const line = '@"my folder/te"';
|
||||
const cursorCol = line.length - 1;
|
||||
const result = provider.getSuggestions([line], 0, cursorCol);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for quoted @ path",
|
||||
);
|
||||
const item = result?.items.find(
|
||||
(entry) => entry.value === '@"my folder/test.txt"',
|
||||
);
|
||||
assert.ok(item, "Should find test.txt suggestion");
|
||||
|
||||
const applied = provider.applyCompletion(
|
||||
[line],
|
||||
0,
|
||||
cursorCol,
|
||||
item!,
|
||||
result!.prefix,
|
||||
);
|
||||
assert.strictEqual(applied.lines[0], '@"my folder/test.txt" ');
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted path completion", () => {
|
||||
let baseDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("quotes paths with spaces for direct completion", () => {
|
||||
setupFolder(baseDir, {
|
||||
dirs: ["my folder"],
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "my";
|
||||
const result = provider.getForceFileSuggestions([line], 0, line.length);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for path completion",
|
||||
);
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('"my folder/"'));
|
||||
});
|
||||
|
||||
test("continues completion inside quoted paths", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
"my folder/other.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = '"my folder/"';
|
||||
const result = provider.getForceFileSuggestions(
|
||||
[line],
|
||||
0,
|
||||
line.length - 1,
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for quoted folder path",
|
||||
);
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes('"my folder/test.txt"'));
|
||||
assert.ok(values?.includes('"my folder/other.txt"'));
|
||||
});
|
||||
|
||||
test("applies quoted completion without duplicating closing quote", () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"my folder/test.txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = '"my folder/te"';
|
||||
const cursorCol = line.length - 1;
|
||||
const result = provider.getForceFileSuggestions([line], 0, cursorCol);
|
||||
|
||||
assert.notEqual(
|
||||
result,
|
||||
null,
|
||||
"Should return suggestions for quoted path",
|
||||
);
|
||||
const item = result?.items.find(
|
||||
(entry) => entry.value === '"my folder/test.txt"',
|
||||
);
|
||||
assert.ok(item, "Should find test.txt suggestion");
|
||||
|
||||
const applied = provider.applyCompletion(
|
||||
[line],
|
||||
0,
|
||||
cursorCol,
|
||||
item!,
|
||||
result!.prefix,
|
||||
);
|
||||
assert.strictEqual(applied.lines[0], '"my folder/test.txt"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
/**
|
||||
* Bug regression test for isImageLine() crash scenario
|
||||
*
|
||||
* Bug: When isImageLine() used startsWith() and terminal doesn't support images,
|
||||
* it would return false for lines containing image escape sequences, causing TUI to
|
||||
* crash with "Rendered line exceeds terminal width" error.
|
||||
*
|
||||
* Fix: Changed to use includes() to detect escape sequences anywhere in the line.
|
||||
*
|
||||
* This test demonstrates:
|
||||
* 1. The bug scenario with the old implementation
|
||||
* 2. That the fix works correctly
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
describe("Bug regression: isImageLine() crash with image escape sequences", () => {
|
||||
describe("Bug scenario: Terminal without image support", () => {
|
||||
it("old implementation would return false, causing crash", () => {
|
||||
/**
|
||||
* OLD IMPLEMENTATION (buggy):
|
||||
* ```typescript
|
||||
* export function isImageLine(line: string): boolean {
|
||||
* const prefix = getImageEscapePrefix();
|
||||
* return prefix !== null && line.startsWith(prefix);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When terminal doesn't support images:
|
||||
* - getImageEscapePrefix() returns null
|
||||
* - isImageLine() returns false even for lines containing image sequences
|
||||
* - TUI performs width check on line containing 300KB+ of base64 data
|
||||
* - Crash: "Rendered line exceeds terminal width (304401 > 115)"
|
||||
*/
|
||||
|
||||
// Simulate old implementation behavior
|
||||
const oldIsImageLine = (
|
||||
line: string,
|
||||
imageEscapePrefix: string | null,
|
||||
): boolean => {
|
||||
return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix);
|
||||
};
|
||||
|
||||
// When terminal doesn't support images, prefix is null
|
||||
const terminalWithoutImageSupport = null;
|
||||
|
||||
// Line containing image escape sequence with text before it (common bug scenario)
|
||||
const lineWithImageSequence =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
|
||||
|
||||
// Old implementation would return false (BUG!)
|
||||
const oldResult = oldIsImageLine(
|
||||
lineWithImageSequence,
|
||||
terminalWithoutImageSupport,
|
||||
);
|
||||
assert.strictEqual(
|
||||
oldResult,
|
||||
false,
|
||||
"Bug: old implementation returns false for line containing image sequence when terminal has no image support",
|
||||
);
|
||||
});
|
||||
|
||||
it("new implementation returns true correctly", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
// Line containing image escape sequence with text before it
|
||||
const lineWithImageSequence =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
|
||||
|
||||
// New implementation should return true (FIX!)
|
||||
const newResult = isImageLine(lineWithImageSequence);
|
||||
assert.strictEqual(
|
||||
newResult,
|
||||
true,
|
||||
"Fix: new implementation returns true for line containing image sequence",
|
||||
);
|
||||
});
|
||||
|
||||
it("new implementation detects Kitty sequences in any position", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
const scenarios = [
|
||||
"At start: \x1b_Ga=T,f=100,data...\x1b\\",
|
||||
"Prefix \x1b_Ga=T,data...\x1b\\",
|
||||
"Suffix text \x1b_Ga=T,data...\x1b\\ suffix",
|
||||
"Middle \x1b_Ga=T,data...\x1b\\ more text",
|
||||
// Very long line (simulating 300KB+ crash scenario)
|
||||
`Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`,
|
||||
];
|
||||
|
||||
for (const line of scenarios) {
|
||||
assert.strictEqual(
|
||||
isImageLine(line),
|
||||
true,
|
||||
`Should detect Kitty sequence in: ${line.slice(0, 50)}...`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("new implementation detects iTerm2 sequences in any position", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
const scenarios = [
|
||||
"At start: \x1b]1337;File=size=100,100:base64...\x07",
|
||||
"Prefix \x1b]1337;File=inline=1:data==\x07",
|
||||
"Suffix text \x1b]1337;File=inline=1:data==\x07 suffix",
|
||||
"Middle \x1b]1337;File=inline=1:data==\x07 more text",
|
||||
// Very long line (simulating 304KB crash scenario)
|
||||
`Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`,
|
||||
];
|
||||
|
||||
for (const line of scenarios) {
|
||||
assert.strictEqual(
|
||||
isImageLine(line),
|
||||
true,
|
||||
`Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration: Tool execution scenario", () => {
|
||||
/**
|
||||
* This simulates what happens when the `read` tool reads an image file.
|
||||
* The tool result contains both text and image content:
|
||||
*
|
||||
* ```typescript
|
||||
* {
|
||||
* content: [
|
||||
* { type: "text", text: "Read image file [image/jpeg]\n800x600" },
|
||||
* { type: "image", data: "base64...", mimeType: "image/jpeg" }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* When this is rendered, the image component creates escape sequences.
|
||||
* If isImageLine() doesn't detect them, TUI crashes.
|
||||
*/
|
||||
|
||||
it("detects image sequences in read tool output", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
// Simulate output when read tool processes an image
|
||||
// The line might have text from the read result plus the image escape sequence
|
||||
const toolOutputLine =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07";
|
||||
|
||||
assert.strictEqual(
|
||||
isImageLine(toolOutputLine),
|
||||
true,
|
||||
"Should detect image sequence in tool output line",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects Kitty sequences from Image component", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
// Kitty image component creates multi-line output with escape sequences
|
||||
const kittyLine =
|
||||
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
|
||||
assert.strictEqual(
|
||||
isImageLine(kittyLine),
|
||||
true,
|
||||
"Should detect Kitty image component output",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles ANSI codes before image sequences", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
// Line might have styling (error, warning, etc.) before image data
|
||||
const lines = [
|
||||
"\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07",
|
||||
"\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\",
|
||||
"\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m",
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(
|
||||
isImageLine(line),
|
||||
true,
|
||||
`Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Crash scenario simulation", () => {
|
||||
it("does NOT crash on very long lines with image sequences", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
/**
|
||||
* Simulate the exact crash scenario:
|
||||
* - Line is 304,401 characters (the crash log showed 58649 > 115)
|
||||
* - Contains image escape sequence somewhere in the middle
|
||||
* - Old implementation would return false, causing TUI to do width check
|
||||
* - New implementation returns true, skipping width check (preventing crash)
|
||||
*/
|
||||
|
||||
const base64Char = "A".repeat(100);
|
||||
const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:";
|
||||
|
||||
// Build a line that would cause the crash
|
||||
const crashLine =
|
||||
"Output: " +
|
||||
iterm2Sequence +
|
||||
base64Char.repeat(3040) + // ~304,000 chars
|
||||
" end of output";
|
||||
|
||||
// Verify line is very long
|
||||
assert(crashLine.length > 300000, "Test line should be > 300KB");
|
||||
|
||||
// New implementation should detect it (prevents crash)
|
||||
const detected = isImageLine(crashLine);
|
||||
assert.strictEqual(
|
||||
detected,
|
||||
true,
|
||||
"Should detect image sequence in very long line, preventing TUI crash",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles lines exactly matching crash log dimensions", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
/**
|
||||
* Crash log showed: line 58649 chars wide, terminal width 115
|
||||
* Let's create a line with similar characteristics
|
||||
*/
|
||||
|
||||
const targetWidth = 58649;
|
||||
const prefix = "Text";
|
||||
const sequence = "\x1b_Ga=T,f=100";
|
||||
const suffix = "End";
|
||||
const padding = "A".repeat(
|
||||
targetWidth - prefix.length - sequence.length - suffix.length,
|
||||
);
|
||||
const line = `${prefix}${sequence}${padding}${suffix}`;
|
||||
|
||||
assert.strictEqual(line.length, 58649);
|
||||
assert.strictEqual(
|
||||
isImageLine(line),
|
||||
true,
|
||||
"Should detect image sequence in 58649-char line",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative cases: Don't false positive", () => {
|
||||
it("does not detect images in regular long text", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
// Very long line WITHOUT image sequences
|
||||
const longText = "A".repeat(100000);
|
||||
|
||||
assert.strictEqual(
|
||||
isImageLine(longText),
|
||||
false,
|
||||
"Should not detect images in plain long text",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not detect images in lines with file paths", async () => {
|
||||
const { isImageLine } = await import("../src/terminal-image.js");
|
||||
|
||||
const filePaths = [
|
||||
"/path/to/1337/image.jpg",
|
||||
"/usr/local/bin/File_converter",
|
||||
"~/Documents/1337File_backup.png",
|
||||
"./_G_test_file.txt",
|
||||
];
|
||||
|
||||
for (const path of filePaths) {
|
||||
assert.strictEqual(
|
||||
isImageLine(path),
|
||||
false,
|
||||
`Should not falsely detect image sequence in path: ${path}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
137
packages/tui/test/chat-simple.ts
Normal file
137
packages/tui/test/chat-simple.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* Simple chat interface demo using tui.ts
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { Loader } from "../src/components/loader.js";
|
||||
import { Markdown } from "../src/components/markdown.js";
|
||||
import { Text } from "../src/components/text.js";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
||||
// Create TUI
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
// Create chat container with some initial messages
|
||||
tui.addChild(
|
||||
new Text(
|
||||
"Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit.",
|
||||
),
|
||||
);
|
||||
|
||||
// Create editor with autocomplete
|
||||
const editor = new Editor(tui, defaultEditorTheme);
|
||||
|
||||
// Set up autocomplete provider with slash commands and file completion
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
[
|
||||
{ name: "delete", description: "Delete the last message" },
|
||||
{ name: "clear", description: "Clear all messages" },
|
||||
],
|
||||
process.cwd(),
|
||||
);
|
||||
editor.setAutocompleteProvider(autocompleteProvider);
|
||||
|
||||
tui.addChild(editor);
|
||||
|
||||
// Focus the editor
|
||||
tui.setFocus(editor);
|
||||
|
||||
// Track if we're waiting for bot response
|
||||
let isResponding = false;
|
||||
|
||||
// Handle message submission
|
||||
editor.onSubmit = (value: string) => {
|
||||
// Prevent submission if already responding
|
||||
if (isResponding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Handle slash commands
|
||||
if (trimmed === "/delete") {
|
||||
const children = tui.children;
|
||||
// Remove component before editor (if there are any besides the initial text)
|
||||
if (children.length > 3) {
|
||||
// children[0] = "Welcome to Simple Chat!"
|
||||
// children[1] = "Type your messages below..."
|
||||
// children[2...n-1] = messages
|
||||
// children[n] = editor
|
||||
children.splice(children.length - 2, 1);
|
||||
}
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed === "/clear") {
|
||||
const children = tui.children;
|
||||
// Remove all messages but keep the welcome text and editor
|
||||
children.splice(2, children.length - 3);
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed) {
|
||||
isResponding = true;
|
||||
editor.disableSubmit = true;
|
||||
|
||||
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
|
||||
|
||||
const children = tui.children;
|
||||
children.splice(children.length - 1, 0, userMessage);
|
||||
|
||||
const loader = new Loader(
|
||||
tui,
|
||||
(s) => chalk.cyan(s),
|
||||
(s) => chalk.dim(s),
|
||||
"Thinking...",
|
||||
);
|
||||
children.splice(children.length - 1, 0, loader);
|
||||
|
||||
tui.requestRender();
|
||||
|
||||
setTimeout(() => {
|
||||
tui.removeChild(loader);
|
||||
|
||||
// Simulate a response
|
||||
const responses = [
|
||||
"That's interesting! Tell me more.",
|
||||
"I see what you mean.",
|
||||
"Fascinating perspective!",
|
||||
"Could you elaborate on that?",
|
||||
"That makes sense to me.",
|
||||
"I hadn't thought of it that way.",
|
||||
"Great point!",
|
||||
"Thanks for sharing that.",
|
||||
];
|
||||
const randomResponse =
|
||||
responses[Math.floor(Math.random() * responses.length)];
|
||||
|
||||
// Add assistant message with no background (transparent)
|
||||
const botMessage = new Markdown(
|
||||
randomResponse,
|
||||
1,
|
||||
1,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
children.splice(children.length - 1, 0, botMessage);
|
||||
|
||||
// Re-enable submit
|
||||
isResponding = false;
|
||||
editor.disableSubmit = false;
|
||||
|
||||
// Request render
|
||||
tui.requestRender();
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the TUI
|
||||
tui.start();
|
||||
2748
packages/tui/test/editor.test.ts
Normal file
2748
packages/tui/test/editor.test.ts
Normal file
File diff suppressed because it is too large
Load diff
102
packages/tui/test/fuzzy.test.ts
Normal file
102
packages/tui/test/fuzzy.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("empty query matches everything with score 0", () => {
|
||||
const result = fuzzyMatch("", "anything");
|
||||
assert.strictEqual(result.matches, true);
|
||||
assert.strictEqual(result.score, 0);
|
||||
});
|
||||
|
||||
it("query longer than text does not match", () => {
|
||||
const result = fuzzyMatch("longquery", "short");
|
||||
assert.strictEqual(result.matches, false);
|
||||
});
|
||||
|
||||
it("exact match has good score", () => {
|
||||
const result = fuzzyMatch("test", "test");
|
||||
assert.strictEqual(result.matches, true);
|
||||
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
|
||||
});
|
||||
|
||||
it("characters must appear in order", () => {
|
||||
const matchInOrder = fuzzyMatch("abc", "aXbXc");
|
||||
assert.strictEqual(matchInOrder.matches, true);
|
||||
|
||||
const matchOutOfOrder = fuzzyMatch("abc", "cba");
|
||||
assert.strictEqual(matchOutOfOrder.matches, false);
|
||||
});
|
||||
|
||||
it("case insensitive matching", () => {
|
||||
const result = fuzzyMatch("ABC", "abc");
|
||||
assert.strictEqual(result.matches, true);
|
||||
|
||||
const result2 = fuzzyMatch("abc", "ABC");
|
||||
assert.strictEqual(result2.matches, true);
|
||||
});
|
||||
|
||||
it("consecutive matches score better than scattered matches", () => {
|
||||
const consecutive = fuzzyMatch("foo", "foobar");
|
||||
const scattered = fuzzyMatch("foo", "f_o_o_bar");
|
||||
|
||||
assert.strictEqual(consecutive.matches, true);
|
||||
assert.strictEqual(scattered.matches, true);
|
||||
assert.ok(consecutive.score < scattered.score);
|
||||
});
|
||||
|
||||
it("word boundary matches score better", () => {
|
||||
const atBoundary = fuzzyMatch("fb", "foo-bar");
|
||||
const notAtBoundary = fuzzyMatch("fb", "afbx");
|
||||
|
||||
assert.strictEqual(atBoundary.matches, true);
|
||||
assert.strictEqual(notAtBoundary.matches, true);
|
||||
assert.ok(atBoundary.score < notAtBoundary.score);
|
||||
});
|
||||
|
||||
it("matches swapped alpha numeric tokens", () => {
|
||||
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
|
||||
assert.strictEqual(result.matches, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyFilter", () => {
|
||||
it("empty query returns all items unchanged", () => {
|
||||
const items = ["apple", "banana", "cherry"];
|
||||
const result = fuzzyFilter(items, "", (x: string) => x);
|
||||
assert.deepStrictEqual(result, items);
|
||||
});
|
||||
|
||||
it("filters out non-matching items", () => {
|
||||
const items = ["apple", "banana", "cherry"];
|
||||
const result = fuzzyFilter(items, "an", (x: string) => x);
|
||||
assert.ok(result.includes("banana"));
|
||||
assert.ok(!result.includes("apple"));
|
||||
assert.ok(!result.includes("cherry"));
|
||||
});
|
||||
|
||||
it("sorts results by match quality", () => {
|
||||
const items = ["a_p_p", "app", "application"];
|
||||
const result = fuzzyFilter(items, "app", (x: string) => x);
|
||||
|
||||
// "app" should be first (exact consecutive match at start)
|
||||
assert.strictEqual(result[0], "app");
|
||||
});
|
||||
|
||||
it("works with custom getText function", () => {
|
||||
const items = [
|
||||
{ name: "foo", id: 1 },
|
||||
{ name: "bar", id: 2 },
|
||||
{ name: "foobar", id: 3 },
|
||||
];
|
||||
const result = fuzzyFilter(
|
||||
items,
|
||||
"foo",
|
||||
(item: { name: string; id: number }) => item.name,
|
||||
);
|
||||
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.ok(result.map((r) => r.name).includes("foo"));
|
||||
assert.ok(result.map((r) => r.name).includes("foobar"));
|
||||
});
|
||||
});
|
||||
62
packages/tui/test/image-test.ts
Normal file
62
packages/tui/test/image-test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { readFileSync } from "fs";
|
||||
import { Image } from "../src/components/image.js";
|
||||
import { Spacer } from "../src/components/spacer.js";
|
||||
import { Text } from "../src/components/text.js";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { getCapabilities, getImageDimensions } from "../src/terminal-image.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
|
||||
const testImagePath = process.argv[2] || "/tmp/test-image.png";
|
||||
|
||||
console.log("Terminal capabilities:", getCapabilities());
|
||||
console.log("Loading image from:", testImagePath);
|
||||
|
||||
let imageBuffer: Buffer;
|
||||
try {
|
||||
imageBuffer = readFileSync(testImagePath);
|
||||
} catch (_e) {
|
||||
console.error(`Failed to load image: ${testImagePath}`);
|
||||
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const dims = getImageDimensions(base64Data, "image/png");
|
||||
|
||||
console.log("Image dimensions:", dims);
|
||||
console.log("");
|
||||
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
tui.addChild(new Text("Image Rendering Test", 1, 1));
|
||||
tui.addChild(new Spacer(1));
|
||||
|
||||
if (dims) {
|
||||
tui.addChild(
|
||||
new Image(
|
||||
base64Data,
|
||||
"image/png",
|
||||
{ fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` },
|
||||
{ maxWidthCells: 60 },
|
||||
dims,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
|
||||
}
|
||||
|
||||
tui.addChild(new Spacer(1));
|
||||
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
|
||||
|
||||
const editor = {
|
||||
handleInput(data: string) {
|
||||
if (data.charCodeAt(0) === 3) {
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
tui.setFocus(editor as any);
|
||||
tui.start();
|
||||
530
packages/tui/test/input.test.ts
Normal file
530
packages/tui/test/input.test.ts
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Input } from "../src/components/input.js";
|
||||
|
||||
describe("Input component", () => {
|
||||
it("submits value including backslash on Enter", () => {
|
||||
const input = new Input();
|
||||
let submitted: string | undefined;
|
||||
|
||||
input.onSubmit = (value) => {
|
||||
submitted = value;
|
||||
};
|
||||
|
||||
// Type hello, then backslash, then Enter
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\\");
|
||||
input.handleInput("\r");
|
||||
|
||||
// Input is single-line, no backslash+Enter workaround
|
||||
assert.strictEqual(submitted, "hello\\");
|
||||
});
|
||||
|
||||
it("inserts backslash as regular character", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("\\");
|
||||
input.handleInput("x");
|
||||
|
||||
assert.strictEqual(input.getValue(), "\\x");
|
||||
});
|
||||
|
||||
describe("Kill ring", () => {
|
||||
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo bar baz");
|
||||
// Move cursor to end
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
// Move to beginning and yank
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "bazfoo bar ");
|
||||
});
|
||||
|
||||
it("Ctrl+U saves deleted text to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
// Move cursor to after "hello "
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x15"); // Ctrl+U - deletes "hello "
|
||||
assert.strictEqual(input.getValue(), "world");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+K saves deleted text to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
input.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("Ctrl+Y does nothing when kill ring is empty", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("test");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "test");
|
||||
});
|
||||
|
||||
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
|
||||
const input = new Input();
|
||||
|
||||
// Create kill ring with multiple entries
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "first"
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "second"
|
||||
input.setValue("third");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "third"
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
|
||||
assert.strictEqual(input.getValue(), "third");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "first"
|
||||
assert.strictEqual(input.getValue(), "first");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles back to "third"
|
||||
assert.strictEqual(input.getValue(), "third");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if not preceded by yank", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("test");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "test"
|
||||
input.setValue("other");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
|
||||
// Type something to break the yank chain
|
||||
input.handleInput("x");
|
||||
assert.strictEqual(input.getValue(), "otherx");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "otherx");
|
||||
});
|
||||
|
||||
it("Alt+Y does nothing if kill ring has one entry", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("only");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "only"
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "only"
|
||||
assert.strictEqual(input.getValue(), "only");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "only");
|
||||
});
|
||||
|
||||
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("one two three");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "three"
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "two "
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "one "
|
||||
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "one two three");
|
||||
});
|
||||
|
||||
it("non-delete actions break kill accumulation", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("foo bar baz");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
input.handleInput("x"); // Typing breaks accumulation
|
||||
assert.strictEqual(input.getValue(), "foo bar x");
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry)
|
||||
assert.strictEqual(input.getValue(), "foo bar ");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - most recent is "x"
|
||||
assert.strictEqual(input.getValue(), "foo bar x");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - cycle to "baz"
|
||||
assert.strictEqual(input.getValue(), "foo bar baz");
|
||||
});
|
||||
|
||||
it("non-yank actions break Alt+Y chain", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
input.setValue("");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
input.handleInput("x"); // Breaks yank chain
|
||||
assert.strictEqual(input.getValue(), "secondx");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - should do nothing
|
||||
assert.strictEqual(input.getValue(), "secondx");
|
||||
});
|
||||
|
||||
it("kill ring rotation persists after cycling", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("first");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "first"
|
||||
input.setValue("second");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "second"
|
||||
input.setValue("third");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // deletes "third"
|
||||
input.setValue("");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
|
||||
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
|
||||
// Break chain and start fresh
|
||||
input.handleInput("x");
|
||||
input.setValue("");
|
||||
|
||||
// New yank should get "second" (now at end after rotation)
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "second");
|
||||
});
|
||||
|
||||
it("backward deletions prepend, forward deletions append during accumulation", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("prefix|suffix");
|
||||
// Position cursor at "|"
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6
|
||||
|
||||
input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward)
|
||||
assert.strictEqual(input.getValue(), "prefix");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "prefix|suffix");
|
||||
});
|
||||
|
||||
it("Alt+D deletes word forward and saves to kill ring", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world test");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
|
||||
assert.strictEqual(input.getValue(), " world test");
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes " world"
|
||||
assert.strictEqual(input.getValue(), " test");
|
||||
|
||||
// Yank should get accumulated text
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello world test");
|
||||
});
|
||||
|
||||
it("handles yank in middle of text", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("word");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "word"
|
||||
input.setValue("hello world");
|
||||
// Move to middle (after "hello ")
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y
|
||||
assert.strictEqual(input.getValue(), "hello wordworld");
|
||||
});
|
||||
|
||||
it("handles yank-pop in middle of text", () => {
|
||||
const input = new Input();
|
||||
|
||||
// Create two kill ring entries
|
||||
input.setValue("FIRST");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
|
||||
input.setValue("SECOND");
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
|
||||
|
||||
// Set up "hello world" and position cursor after "hello "
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND"
|
||||
assert.strictEqual(input.getValue(), "hello SECONDworld");
|
||||
|
||||
input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST"
|
||||
assert.strictEqual(input.getValue(), "hello FIRSTworld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Undo", () => {
|
||||
it("does nothing when undo stack is empty", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("coalesces consecutive word characters into one undo unit", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
|
||||
// Undo removes " world"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
|
||||
// Undo removes "hello"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes spaces one at a time", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput(" ");
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes backspace", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\x7f"); // Backspace
|
||||
assert.strictEqual(input.getValue(), "hell");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
});
|
||||
|
||||
it("undoes forward delete", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput("\x01"); // Ctrl+A - go to start
|
||||
input.handleInput("\x1b[C"); // Right arrow
|
||||
input.handleInput("\x1b[3~"); // Delete key
|
||||
assert.strictEqual(input.getValue(), "hllo");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+W (delete word backward)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
|
||||
input.handleInput("\x17"); // Ctrl+W
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+K (delete to line end)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x0b"); // Ctrl+K
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Ctrl+U (delete to line start)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("w");
|
||||
input.handleInput("o");
|
||||
input.handleInput("r");
|
||||
input.handleInput("l");
|
||||
input.handleInput("d");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
|
||||
|
||||
input.handleInput("\x15"); // Ctrl+U
|
||||
assert.strictEqual(input.getValue(), "world");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes yank", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("h");
|
||||
input.handleInput("e");
|
||||
input.handleInput("l");
|
||||
input.handleInput("l");
|
||||
input.handleInput("o");
|
||||
input.handleInput(" ");
|
||||
input.handleInput("\x17"); // Ctrl+W - delete "hello "
|
||||
input.handleInput("\x19"); // Ctrl+Y - yank
|
||||
assert.strictEqual(input.getValue(), "hello ");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
|
||||
it("undoes paste atomically", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
|
||||
|
||||
// Simulate bracketed paste
|
||||
input.handleInput("\x1b[200~beep boop\x1b[201~");
|
||||
assert.strictEqual(input.getValue(), "hellobeep boop world");
|
||||
|
||||
// Single undo should restore entire pre-paste state
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("undoes Alt+D (delete word forward)", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.setValue("hello world");
|
||||
input.handleInput("\x01"); // Ctrl+A
|
||||
|
||||
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
|
||||
assert.strictEqual(input.getValue(), " world");
|
||||
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "hello world");
|
||||
});
|
||||
|
||||
it("cursor movement starts new undo unit", () => {
|
||||
const input = new Input();
|
||||
|
||||
input.handleInput("a");
|
||||
input.handleInput("b");
|
||||
input.handleInput("c");
|
||||
input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing
|
||||
input.handleInput("\x05"); // Ctrl+E
|
||||
input.handleInput("d");
|
||||
input.handleInput("e");
|
||||
assert.strictEqual(input.getValue(), "abcde");
|
||||
|
||||
// Undo removes "de" (typed after movement)
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "abc");
|
||||
|
||||
// Undo removes "abc"
|
||||
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(input.getValue(), "");
|
||||
});
|
||||
});
|
||||
});
|
||||
113
packages/tui/test/key-tester.ts
Executable file
113
packages/tui/test/key-tester.ts
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
import { matchesKey } from "../src/keys.js";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
|
||||
/**
|
||||
* Simple key code logger component
|
||||
*/
|
||||
class KeyLogger implements Component {
|
||||
private log: string[] = [];
|
||||
private maxLines = 20;
|
||||
private tui: TUI;
|
||||
|
||||
constructor(tui: TUI) {
|
||||
this.tui = tui;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Handle Ctrl+C (raw or Kitty protocol) for exit
|
||||
if (matchesKey(data, "ctrl+c")) {
|
||||
this.tui.stop();
|
||||
console.log("\nExiting...");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Convert to various representations
|
||||
const hex = Buffer.from(data).toString("hex");
|
||||
const charCodes = Array.from(data)
|
||||
.map((c) => c.charCodeAt(0))
|
||||
.join(", ");
|
||||
const repr = data
|
||||
.replace(/\x1b/g, "\\x1b")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\t/g, "\\t")
|
||||
.replace(/\x7f/g, "\\x7f");
|
||||
|
||||
const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`;
|
||||
|
||||
this.log.push(logLine);
|
||||
|
||||
// Keep only last N lines
|
||||
if (this.log.length > this.maxLines) {
|
||||
this.log.shift();
|
||||
}
|
||||
|
||||
// Request re-render to show the new log entry
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Title
|
||||
lines.push("=".repeat(width));
|
||||
lines.push(
|
||||
"Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(
|
||||
width,
|
||||
),
|
||||
);
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("");
|
||||
|
||||
// Log entries
|
||||
for (const entry of this.log) {
|
||||
lines.push(entry.padEnd(width));
|
||||
}
|
||||
|
||||
// Fill remaining space
|
||||
const remaining = Math.max(0, 25 - lines.length);
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
lines.push("".padEnd(width));
|
||||
}
|
||||
|
||||
// Footer
|
||||
lines.push("=".repeat(width));
|
||||
lines.push("Test these:".padEnd(width));
|
||||
lines.push(
|
||||
" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(
|
||||
width,
|
||||
),
|
||||
);
|
||||
lines.push(" - Alt/Option + Enter".padEnd(width));
|
||||
lines.push(" - Option/Alt + Backspace".padEnd(width));
|
||||
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
|
||||
lines.push(" - Regular Backspace".padEnd(width));
|
||||
lines.push("=".repeat(width));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up TUI
|
||||
const terminal = new ProcessTerminal();
|
||||
const tui = new TUI(terminal);
|
||||
const logger = new KeyLogger(tui);
|
||||
|
||||
tui.addChild(logger);
|
||||
tui.setFocus(logger);
|
||||
|
||||
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
|
||||
process.on("SIGINT", () => {
|
||||
tui.stop();
|
||||
console.log("\nExiting...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the TUI
|
||||
tui.start();
|
||||
349
packages/tui/test/keys.test.ts
Normal file
349
packages/tui/test/keys.test.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/**
|
||||
* Tests for keyboard input handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
|
||||
|
||||
describe("matchesKey", () => {
|
||||
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
|
||||
// Kitty protocol flag 4 (Report alternate keys) sends:
|
||||
// CSI codepoint:shifted:base ; modifier:event u
|
||||
// Where base is the key in standard PC-101 layout
|
||||
|
||||
it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99
|
||||
// Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5)
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100
|
||||
const cyrillicCtrlD = "\x1b[1074::100;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122
|
||||
const cyrillicCtrlZ = "\x1b[1103::122;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match Ctrl+Shift+p with base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112
|
||||
// ctrl=4, shift=1, +1 = 6
|
||||
const cyrillicCtrlShiftP = "\x1b[1079::112;6u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should still match direct codepoint when no base layout key", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Latin ctrl+c without base layout key (terminal doesn't support flag 4)
|
||||
const latinCtrlC = "\x1b[99;5u";
|
||||
assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle shifted key in format", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Format with shifted key: CSI codepoint:shifted:base;modifier u
|
||||
// Latin 'c' with shifted 'C' (67) and base 'c' (99)
|
||||
const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2
|
||||
assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle event type in format", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Format with event type: CSI codepoint::base;modifier:event u
|
||||
// Cyrillic ctrl+c release event (event type 3)
|
||||
const releaseEvent = "\x1b[1089::99;5:3u";
|
||||
assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should handle full format with shifted key, base key, and event type", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Full format: CSI codepoint:shifted:base;modifier:event u
|
||||
// Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event
|
||||
// Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99
|
||||
// ctrl=4, shift=1, +1 = 6, repeat event = 2
|
||||
const fullFormat = "\x1b[1089:1057:99;6:2u";
|
||||
assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for Latin letters even when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
|
||||
const dvorakCtrlK = "\x1b[107::118;5u";
|
||||
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
|
||||
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for symbol keys even when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
|
||||
const dvorakCtrlSlash = "\x1b[47::91;5u";
|
||||
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
|
||||
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should not match wrong key even with base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с with base 'c' should NOT match ctrl+d
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should not match wrong modifiers even with base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с should NOT match ctrl+shift+c
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy key matching", () => {
|
||||
it("should match legacy Ctrl+c", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+c sends ASCII 3 (ETX)
|
||||
assert.strictEqual(matchesKey("\x03", "ctrl+c"), true);
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+d", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+d sends ASCII 4 (EOT)
|
||||
assert.strictEqual(matchesKey("\x04", "ctrl+d"), true);
|
||||
});
|
||||
|
||||
it("should match escape key", () => {
|
||||
assert.strictEqual(matchesKey("\x1b", "escape"), true);
|
||||
});
|
||||
|
||||
it("should match legacy linefeed as enter", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\n", "enter"), true);
|
||||
assert.strictEqual(parseKey("\n"), "enter");
|
||||
});
|
||||
|
||||
it("should treat linefeed as shift+enter when kitty active", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
|
||||
assert.strictEqual(matchesKey("\n", "enter"), false);
|
||||
assert.strictEqual(parseKey("\n"), "shift+enter");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should parse ctrl+space", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
|
||||
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+symbol", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals
|
||||
assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true);
|
||||
assert.strictEqual(parseKey("\x1c"), "ctrl+\\");
|
||||
// Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals
|
||||
assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true);
|
||||
assert.strictEqual(parseKey("\x1d"), "ctrl+]");
|
||||
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
|
||||
// Ctrl+- is on the same physical key on US keyboards
|
||||
assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true);
|
||||
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
|
||||
assert.strictEqual(parseKey("\x1f"), "ctrl+-");
|
||||
});
|
||||
|
||||
it("should match legacy Ctrl+Alt+symbol", () => {
|
||||
setKittyProtocolActive(false);
|
||||
// Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC)
|
||||
assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+[");
|
||||
// Ctrl+Alt+\ sends ESC followed by ASCII 28
|
||||
assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\");
|
||||
// Ctrl+Alt+] sends ESC followed by ASCII 29
|
||||
assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]");
|
||||
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
|
||||
// Ctrl+- is on the same physical key on US keyboards
|
||||
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true);
|
||||
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
|
||||
});
|
||||
|
||||
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
|
||||
assert.strictEqual(parseKey("\x1b "), "alt+space");
|
||||
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
|
||||
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
|
||||
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
|
||||
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
|
||||
assert.strictEqual(parseKey("\x1bB"), "alt+left");
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
|
||||
assert.strictEqual(parseKey("\x1bF"), "alt+right");
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
|
||||
assert.strictEqual(parseKey("\x1ba"), "alt+a");
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
|
||||
assert.strictEqual(parseKey("\x1by"), "alt+y");
|
||||
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
|
||||
assert.strictEqual(parseKey("\x1bz"), "alt+z");
|
||||
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
|
||||
assert.strictEqual(parseKey("\x1b "), undefined);
|
||||
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
|
||||
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
|
||||
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
|
||||
assert.strictEqual(parseKey("\x1b\x03"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
|
||||
assert.strictEqual(parseKey("\x1bB"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
|
||||
assert.strictEqual(parseKey("\x1bF"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
|
||||
assert.strictEqual(parseKey("\x1ba"), undefined);
|
||||
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
|
||||
assert.strictEqual(parseKey("\x1by"), undefined);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should match arrow keys", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[A", "up"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[B", "down"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[C", "right"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[D", "left"), true);
|
||||
});
|
||||
|
||||
it("should match SS3 arrows and home/end", () => {
|
||||
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
|
||||
});
|
||||
|
||||
it("should match legacy function keys and clear", () => {
|
||||
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
|
||||
});
|
||||
|
||||
it("should match alt+arrows", () => {
|
||||
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bp", "up"), false);
|
||||
});
|
||||
|
||||
it("should match rxvt modifier sequences", () => {
|
||||
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
|
||||
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKey", () => {
|
||||
describe("Kitty protocol with alternate keys", () => {
|
||||
it("should return Latin key name when base layout key is present", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Cyrillic ctrl+с with base layout 'c'
|
||||
const cyrillicCtrlC = "\x1b[1089::99;5u";
|
||||
assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for Latin letters when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
|
||||
const dvorakCtrlK = "\x1b[107::118;5u";
|
||||
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should prefer codepoint for symbol keys when base layout differs", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
|
||||
const dvorakCtrlSlash = "\x1b[47::91;5u";
|
||||
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should return key name from codepoint when no base layout", () => {
|
||||
setKittyProtocolActive(true);
|
||||
const latinCtrlC = "\x1b[99;5u";
|
||||
assert.strictEqual(parseKey(latinCtrlC), "ctrl+c");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
assert.strictEqual(parseKey("\x1b[99;9u"), undefined);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy key parsing", () => {
|
||||
it("should parse legacy Ctrl+letter", () => {
|
||||
setKittyProtocolActive(false);
|
||||
assert.strictEqual(parseKey("\x03"), "ctrl+c");
|
||||
assert.strictEqual(parseKey("\x04"), "ctrl+d");
|
||||
});
|
||||
|
||||
it("should parse special keys", () => {
|
||||
assert.strictEqual(parseKey("\x1b"), "escape");
|
||||
assert.strictEqual(parseKey("\t"), "tab");
|
||||
assert.strictEqual(parseKey("\r"), "enter");
|
||||
assert.strictEqual(parseKey("\n"), "enter");
|
||||
assert.strictEqual(parseKey("\x00"), "ctrl+space");
|
||||
assert.strictEqual(parseKey(" "), "space");
|
||||
});
|
||||
|
||||
it("should parse arrow keys", () => {
|
||||
assert.strictEqual(parseKey("\x1b[A"), "up");
|
||||
assert.strictEqual(parseKey("\x1b[B"), "down");
|
||||
assert.strictEqual(parseKey("\x1b[C"), "right");
|
||||
assert.strictEqual(parseKey("\x1b[D"), "left");
|
||||
});
|
||||
|
||||
it("should parse SS3 arrows and home/end", () => {
|
||||
assert.strictEqual(parseKey("\x1bOA"), "up");
|
||||
assert.strictEqual(parseKey("\x1bOB"), "down");
|
||||
assert.strictEqual(parseKey("\x1bOC"), "right");
|
||||
assert.strictEqual(parseKey("\x1bOD"), "left");
|
||||
assert.strictEqual(parseKey("\x1bOH"), "home");
|
||||
assert.strictEqual(parseKey("\x1bOF"), "end");
|
||||
});
|
||||
|
||||
it("should parse legacy function and modifier sequences", () => {
|
||||
assert.strictEqual(parseKey("\x1bOP"), "f1");
|
||||
assert.strictEqual(parseKey("\x1b[24~"), "f12");
|
||||
assert.strictEqual(parseKey("\x1b[E"), "clear");
|
||||
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
|
||||
assert.strictEqual(parseKey("\x1bp"), "alt+up");
|
||||
});
|
||||
|
||||
it("should parse double bracket pageUp", () => {
|
||||
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
|
||||
});
|
||||
});
|
||||
});
|
||||
1223
packages/tui/test/markdown.test.ts
Normal file
1223
packages/tui/test/markdown.test.ts
Normal file
File diff suppressed because it is too large
Load diff
626
packages/tui/test/overlay-options.test.ts
Normal file
626
packages/tui/test/overlay-options.test.ts
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Component } from "../src/tui.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
constructor(
|
||||
private lines: string[],
|
||||
public requestedWidth?: number,
|
||||
) {}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Store the width we were asked to render at for verification
|
||||
this.requestedWidth = width;
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class EmptyContent implements Component {
|
||||
render(): string[] {
|
||||
return [];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
async function renderAndFlush(
|
||||
tui: TUI,
|
||||
terminal: VirtualTerminal,
|
||||
): Promise<void> {
|
||||
tui.requestRender(true);
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
}
|
||||
|
||||
describe("TUI overlay options", () => {
|
||||
describe("width overflow protection", () => {
|
||||
it("should truncate overlay lines that exceed declared width", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
// Overlay declares width 20 but renders lines much wider
|
||||
const overlay = new StaticOverlay(["X".repeat(100)]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash, and no line should exceed terminal width
|
||||
const viewport = terminal.getViewport();
|
||||
for (const line of viewport) {
|
||||
// visibleWidth not available here, but line length is a rough check
|
||||
// The important thing is it didn't crash
|
||||
assert.ok(line !== undefined);
|
||||
}
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay with complex ANSI sequences without crashing", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
// Simulate complex ANSI content like the crash log showed
|
||||
const complexLine =
|
||||
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
|
||||
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
|
||||
" more content ".repeat(10);
|
||||
const overlay = new StaticOverlay([
|
||||
complexLine,
|
||||
complexLine,
|
||||
complexLine,
|
||||
]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 60 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay composited on styled base content", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
// Base content with styling
|
||||
class StyledContent implements Component {
|
||||
render(width: number): string[] {
|
||||
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
|
||||
return [styledLine, styledLine, styledLine];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const overlay = new StaticOverlay(["OVERLAY"]);
|
||||
|
||||
tui.addChild(new StyledContent());
|
||||
tui.showOverlay(overlay, { width: 20, anchor: "center" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash and overlay should be visible
|
||||
const viewport = terminal.getViewport();
|
||||
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
|
||||
assert.ok(hasOverlay, "Overlay should be visible");
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle wide characters at overlay boundary", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
// Wide chars (each takes 2 columns) at the edge of declared width
|
||||
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
|
||||
const overlay = new StaticOverlay([wideCharLine]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay positioned at terminal edge", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
// Overlay positioned at right edge with content that exceeds declared width
|
||||
const overlay = new StaticOverlay(["X".repeat(50)]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Position at col 60 with width 20 - should fit exactly at right edge
|
||||
tui.showOverlay(overlay, { col: 60, width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlay on base content with OSC sequences", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
// Base content with OSC 8 hyperlinks (like file paths in agent output)
|
||||
class HyperlinkContent implements Component {
|
||||
render(width: number): string[] {
|
||||
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
|
||||
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
|
||||
return [line, line, line];
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
|
||||
|
||||
tui.addChild(new HyperlinkContent());
|
||||
tui.showOverlay(overlay, { anchor: "center", width: 20 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Should not crash - this was the original bug scenario
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport.length > 0);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("width percentage", () => {
|
||||
it("should render overlay at percentage of terminal width", async () => {
|
||||
const terminal = new VirtualTerminal(100, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(overlay.requestedWidth, 50);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect minWidth when widthPercent results in smaller width", async () => {
|
||||
const terminal = new VirtualTerminal(100, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(overlay.requestedWidth, 30);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor positioning", () => {
|
||||
it("should position overlay at top-left", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["TOP-LEFT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.startsWith("TOP-LEFT"),
|
||||
`Expected TOP-LEFT at start, got: ${viewport[0]}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should position overlay at bottom-right", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["BTM-RIGHT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on last row, ending at last column
|
||||
const lastRow = viewport[23];
|
||||
assert.ok(
|
||||
lastRow?.includes("BTM-RIGHT"),
|
||||
`Expected BTM-RIGHT on last row, got: ${lastRow}`,
|
||||
);
|
||||
assert.ok(
|
||||
lastRow?.trimEnd().endsWith("BTM-RIGHT"),
|
||||
`Expected BTM-RIGHT at end, got: ${lastRow}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should position overlay at top-center", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["CENTERED"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on first row, centered horizontally
|
||||
const firstRow = viewport[0];
|
||||
assert.ok(
|
||||
firstRow?.includes("CENTERED"),
|
||||
`Expected CENTERED on first row, got: ${firstRow}`,
|
||||
);
|
||||
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
|
||||
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
|
||||
assert.ok(
|
||||
colIndex >= 30 && colIndex <= 40,
|
||||
`Expected centered, got col ${colIndex}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("margin", () => {
|
||||
it("should clamp negative margins to zero", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["NEG-MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Negative margins should be treated as 0
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "top-left",
|
||||
width: 12,
|
||||
margin: { top: -5, left: -10, right: 0, bottom: 0 },
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be at row 0, col 0 (negative margins clamped to 0)
|
||||
assert.ok(
|
||||
viewport[0]?.startsWith("NEG-MARGIN"),
|
||||
`Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect margin as number", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Should be on row 5 (not 0) due to margin
|
||||
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
|
||||
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
|
||||
assert.ok(
|
||||
viewport[5]?.includes("MARGIN"),
|
||||
`Expected MARGIN on row 5, got: ${viewport[5]}`,
|
||||
);
|
||||
// Should start at col 5 (not 0)
|
||||
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
|
||||
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should respect margin object", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["MARGIN"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "top-left",
|
||||
width: 10,
|
||||
margin: { top: 2, left: 3, right: 0, bottom: 0 },
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[2]?.includes("MARGIN"),
|
||||
`Expected MARGIN on row 2, got: ${viewport[2]}`,
|
||||
);
|
||||
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
|
||||
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offset", () => {
|
||||
it("should apply offsetX and offsetY from anchor position", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["OFFSET"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "top-left",
|
||||
width: 10,
|
||||
offsetX: 10,
|
||||
offsetY: 5,
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[5]?.includes("OFFSET"),
|
||||
`Expected OFFSET on row 5, got: ${viewport[5]}`,
|
||||
);
|
||||
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
|
||||
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentage positioning", () => {
|
||||
it("should position with rowPercent and colPercent", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["PCT"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// 50% should center both ways
|
||||
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Find the row with PCT
|
||||
let foundRow = -1;
|
||||
for (let i = 0; i < viewport.length; i++) {
|
||||
if (viewport[i]?.includes("PCT")) {
|
||||
foundRow = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
|
||||
assert.ok(
|
||||
foundRow >= 10 && foundRow <= 13,
|
||||
`Expected centered row, got ${foundRow}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("rowPercent 0 should position at top", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["TOP"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, row: "0%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("TOP"),
|
||||
`Expected TOP on row 0, got: ${viewport[0]}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("rowPercent 100 should position at bottom", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["BOTTOM"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, row: "100%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[23]?.includes("BOTTOM"),
|
||||
`Expected BOTTOM on last row, got: ${viewport[23]}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxHeight", () => {
|
||||
it("should truncate overlay to maxHeight", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay([
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
"Line 3",
|
||||
"Line 4",
|
||||
"Line 5",
|
||||
]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { maxHeight: 3 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const content = viewport.join("\n");
|
||||
assert.ok(content.includes("Line 1"), "Should include Line 1");
|
||||
assert.ok(content.includes("Line 2"), "Should include Line 2");
|
||||
assert.ok(content.includes("Line 3"), "Should include Line 3");
|
||||
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
|
||||
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should truncate overlay to maxHeightPercent", async () => {
|
||||
const terminal = new VirtualTerminal(80, 10);
|
||||
const tui = new TUI(terminal);
|
||||
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
|
||||
const overlay = new StaticOverlay([
|
||||
"L1",
|
||||
"L2",
|
||||
"L3",
|
||||
"L4",
|
||||
"L5",
|
||||
"L6",
|
||||
"L7",
|
||||
"L8",
|
||||
"L9",
|
||||
"L10",
|
||||
]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { maxHeight: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const content = viewport.join("\n");
|
||||
assert.ok(content.includes("L1"), "Should include L1");
|
||||
assert.ok(content.includes("L5"), "Should include L5");
|
||||
assert.ok(!content.includes("L6"), "Should NOT include L6");
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("absolute positioning", () => {
|
||||
it("row and col should override anchor", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["ABSOLUTE"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
// Even with bottom-right anchor, row/col should win
|
||||
tui.showOverlay(overlay, {
|
||||
anchor: "bottom-right",
|
||||
row: 3,
|
||||
col: 5,
|
||||
width: 10,
|
||||
});
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[3]?.includes("ABSOLUTE"),
|
||||
`Expected ABSOLUTE on row 3, got: ${viewport[3]}`,
|
||||
);
|
||||
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
|
||||
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stacked overlays", () => {
|
||||
it("should render multiple overlays with later ones on top", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// First overlay at top-left
|
||||
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
|
||||
|
||||
// Second overlay at top-left (should cover part of first)
|
||||
const overlay2 = new StaticOverlay(["SECOND"]);
|
||||
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Second overlay should be visible (on top)
|
||||
assert.ok(
|
||||
viewport[0]?.includes("SECOND"),
|
||||
`Expected SECOND on row 0, got: ${viewport[0]}`,
|
||||
);
|
||||
// Part of first overlay might still be visible after SECOND
|
||||
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should handle overlays at different positions without interference", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// Overlay at top-left
|
||||
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
|
||||
|
||||
// Overlay at bottom-right
|
||||
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
|
||||
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Both should be visible
|
||||
assert.ok(
|
||||
viewport[0]?.includes("TOP-LEFT"),
|
||||
`Expected TOP-LEFT on row 0, got: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[23]?.includes("BTM-RIGHT"),
|
||||
`Expected BTM-RIGHT on row 23, got: ${viewport[23]}`,
|
||||
);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should properly hide overlays in stack order", async () => {
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
|
||||
// Show two overlays
|
||||
const overlay1 = new StaticOverlay(["FIRST"]);
|
||||
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
|
||||
|
||||
const overlay2 = new StaticOverlay(["SECOND"]);
|
||||
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
|
||||
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// Second should be visible
|
||||
let viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("SECOND"),
|
||||
"SECOND should be visible initially",
|
||||
);
|
||||
|
||||
// Hide top overlay
|
||||
tui.hideOverlay();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
// First should now be visible
|
||||
viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("FIRST"),
|
||||
"FIRST should be visible after hiding SECOND",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
});
|
||||
60
packages/tui/test/overlay-short-content.test.ts
Normal file
60
packages/tui/test/overlay-short-content.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
|
||||
class SimpleContent implements Component {
|
||||
constructor(private lines: string[]) {}
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
class SimpleOverlay implements Component {
|
||||
render(): string[] {
|
||||
return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"];
|
||||
}
|
||||
invalidate() {}
|
||||
}
|
||||
|
||||
describe("TUI overlay with short content", () => {
|
||||
it("should render overlay when content is shorter than terminal height", async () => {
|
||||
// Terminal has 24 rows, but content only has 3 lines
|
||||
const terminal = new VirtualTerminal(80, 24);
|
||||
const tui = new TUI(terminal);
|
||||
|
||||
// Only 3 lines of content
|
||||
tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"]));
|
||||
|
||||
// Show overlay centered - should be around row 10 in a 24-row terminal
|
||||
const overlay = new SimpleOverlay();
|
||||
tui.showOverlay(overlay);
|
||||
|
||||
// Trigger render
|
||||
tui.start();
|
||||
await new Promise((r) => process.nextTick(r));
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));
|
||||
|
||||
console.log("Terminal rows:", terminal.rows);
|
||||
console.log("Content lines: 3");
|
||||
console.log("Overlay visible:", hasOverlay);
|
||||
|
||||
if (!hasOverlay) {
|
||||
console.log("\nViewport contents:");
|
||||
for (let i = 0; i < viewport.length; i++) {
|
||||
console.log(` [${i}]: "${viewport[i]}"`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
hasOverlay,
|
||||
"Overlay should be visible when content is shorter than terminal",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
|
||||
|
||||
describe("regional indicator width regression", () => {
|
||||
it("treats partial flag grapheme as full-width to avoid streaming render drift", () => {
|
||||
// Repro context:
|
||||
// During streaming, "🇨🇳" often appears as an intermediate "🇨" first.
|
||||
// If "🇨" is measured as width 1 while terminal renders it as width 2,
|
||||
// differential rendering can drift and leave stale characters on screen.
|
||||
const partialFlag = "🇨";
|
||||
const listLine = " - 🇨";
|
||||
|
||||
assert.strictEqual(visibleWidth(partialFlag), 2);
|
||||
assert.strictEqual(visibleWidth(listLine), 10);
|
||||
});
|
||||
|
||||
it("wraps intermediate partial-flag list line before overflow", () => {
|
||||
// Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10).
|
||||
// This must wrap to avoid terminal auto-wrap mismatch.
|
||||
const wrapped = wrapTextWithAnsi(" - 🇨", 9);
|
||||
|
||||
assert.strictEqual(wrapped.length, 2);
|
||||
assert.strictEqual(visibleWidth(wrapped[0] || ""), 7);
|
||||
assert.strictEqual(visibleWidth(wrapped[1] || ""), 2);
|
||||
});
|
||||
|
||||
it("treats all regional-indicator singleton graphemes as width 2", () => {
|
||||
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
|
||||
const regionalIndicator = String.fromCodePoint(cp);
|
||||
assert.strictEqual(
|
||||
visibleWidth(regionalIndicator),
|
||||
2,
|
||||
`Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps full flag pairs at width 2", () => {
|
||||
const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"];
|
||||
for (const flag of samples) {
|
||||
assert.strictEqual(
|
||||
visibleWidth(flag),
|
||||
2,
|
||||
`Expected ${flag} to be width 2`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps common streaming emoji intermediates at stable width", () => {
|
||||
const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨💻", "🏳️🌈"];
|
||||
for (const sample of samples) {
|
||||
assert.strictEqual(
|
||||
visibleWidth(sample),
|
||||
2,
|
||||
`Expected ${sample} to be width 2`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
30
packages/tui/test/select-list.test.ts
Normal file
30
packages/tui/test/select-list.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { SelectList } from "../src/components/select-list.js";
|
||||
|
||||
const testTheme = {
|
||||
selectedPrefix: (text: string) => text,
|
||||
selectedText: (text: string) => text,
|
||||
description: (text: string) => text,
|
||||
scrollInfo: (text: string) => text,
|
||||
noMatch: (text: string) => text,
|
||||
};
|
||||
|
||||
describe("SelectList", () => {
|
||||
it("normalizes multiline descriptions to single line", () => {
|
||||
const items = [
|
||||
{
|
||||
value: "test",
|
||||
label: "test",
|
||||
description: "Line one\nLine two\nLine three",
|
||||
},
|
||||
];
|
||||
|
||||
const list = new SelectList(items, 5, testTheme);
|
||||
const rendered = list.render(100);
|
||||
|
||||
assert.ok(rendered.length > 0);
|
||||
assert.ok(!rendered[0].includes("\n"));
|
||||
assert.ok(rendered[0].includes("Line one Line two Line three"));
|
||||
});
|
||||
});
|
||||
450
packages/tui/test/stdin-buffer.test.ts
Normal file
450
packages/tui/test/stdin-buffer.test.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
/**
|
||||
* Tests for StdinBuffer
|
||||
*
|
||||
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
||||
* MIT License - Copyright (c) 2025 opentui
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
import { StdinBuffer } from "../src/stdin-buffer.js";
|
||||
|
||||
describe("StdinBuffer", () => {
|
||||
let buffer: StdinBuffer;
|
||||
let emittedSequences: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = new StdinBuffer({ timeout: 10 });
|
||||
|
||||
// Collect emitted sequences
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to process data through the buffer
|
||||
function processInput(data: string | Buffer): void {
|
||||
buffer.process(data);
|
||||
}
|
||||
|
||||
// Helper to wait for async operations
|
||||
async function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("Regular Characters", () => {
|
||||
it("should pass through regular characters immediately", () => {
|
||||
processInput("a");
|
||||
assert.deepStrictEqual(emittedSequences, ["a"]);
|
||||
});
|
||||
|
||||
it("should pass through multiple regular characters", () => {
|
||||
processInput("abc");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle unicode characters", () => {
|
||||
processInput("hello 世界");
|
||||
assert.deepStrictEqual(emittedSequences, [
|
||||
"h",
|
||||
"e",
|
||||
"l",
|
||||
"l",
|
||||
"o",
|
||||
" ",
|
||||
"世",
|
||||
"界",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complete Escape Sequences", () => {
|
||||
it("should pass through complete mouse SGR sequences", () => {
|
||||
const mouseSeq = "\x1b[<35;20;5m";
|
||||
processInput(mouseSeq);
|
||||
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
|
||||
});
|
||||
|
||||
it("should pass through complete arrow key sequences", () => {
|
||||
const upArrow = "\x1b[A";
|
||||
processInput(upArrow);
|
||||
assert.deepStrictEqual(emittedSequences, [upArrow]);
|
||||
});
|
||||
|
||||
it("should pass through complete function key sequences", () => {
|
||||
const f1 = "\x1b[11~";
|
||||
processInput(f1);
|
||||
assert.deepStrictEqual(emittedSequences, [f1]);
|
||||
});
|
||||
|
||||
it("should pass through meta key sequences", () => {
|
||||
const metaA = "\x1ba";
|
||||
processInput(metaA);
|
||||
assert.deepStrictEqual(emittedSequences, [metaA]);
|
||||
});
|
||||
|
||||
it("should pass through SS3 sequences", () => {
|
||||
const ss3 = "\x1bOA";
|
||||
processInput(ss3);
|
||||
assert.deepStrictEqual(emittedSequences, [ss3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Partial Escape Sequences", () => {
|
||||
it("should buffer incomplete mouse SGR sequence", async () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b");
|
||||
|
||||
processInput("[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
processInput(";20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should buffer incomplete CSI sequence", () => {
|
||||
processInput("\x1b[");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
processInput("1;");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
processInput("5H");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
|
||||
});
|
||||
|
||||
it("should buffer split across many chunks", () => {
|
||||
processInput("\x1b");
|
||||
processInput("[");
|
||||
processInput("<");
|
||||
processInput("3");
|
||||
processInput("5");
|
||||
processInput(";");
|
||||
processInput("2");
|
||||
processInput("0");
|
||||
processInput(";");
|
||||
processInput("5");
|
||||
processInput("m");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
});
|
||||
|
||||
it("should flush incomplete sequence after timeout", async () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// Wait for timeout
|
||||
await wait(15);
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed Content", () => {
|
||||
it("should handle characters followed by escape sequence", () => {
|
||||
processInput("abc\x1b[A");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
|
||||
});
|
||||
|
||||
it("should handle escape sequence followed by characters", () => {
|
||||
processInput("\x1b[Aabc");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle multiple complete sequences", () => {
|
||||
processInput("\x1b[A\x1b[B\x1b[C");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
|
||||
});
|
||||
|
||||
it("should handle partial sequence with preceding characters", () => {
|
||||
processInput("abc\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
processInput(";20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"\x1b[<35;20;5m",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty Keyboard Protocol", () => {
|
||||
it("should handle Kitty CSI u press events", () => {
|
||||
// Press 'a' in Kitty protocol
|
||||
processInput("\x1b[97u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty CSI u release events", () => {
|
||||
// Release 'a' in Kitty protocol
|
||||
processInput("\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle batched Kitty press and release", () => {
|
||||
// Press 'a', release 'a' batched together (common over SSH)
|
||||
processInput("\x1b[97u\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle multiple batched Kitty events", () => {
|
||||
// Press 'a', release 'a', press 'b', release 'b'
|
||||
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, [
|
||||
"\x1b[97u",
|
||||
"\x1b[97;1:3u",
|
||||
"\x1b[98u",
|
||||
"\x1b[98;1:3u",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle Kitty arrow keys with event type", () => {
|
||||
// Up arrow press with event type
|
||||
processInput("\x1b[1;1:1A");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty functional keys with event type", () => {
|
||||
// Delete key release
|
||||
processInput("\x1b[3;1:3~");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
|
||||
});
|
||||
|
||||
it("should handle plain characters mixed with Kitty sequences", () => {
|
||||
// Plain 'a' followed by Kitty release
|
||||
processInput("a\x1b[97;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
|
||||
});
|
||||
|
||||
it("should handle Kitty sequence followed by plain characters", () => {
|
||||
processInput("\x1b[97ua");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]);
|
||||
});
|
||||
|
||||
it("should handle rapid typing simulation with Kitty protocol", () => {
|
||||
// Simulates typing "hi" quickly with releases interleaved
|
||||
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
|
||||
assert.deepStrictEqual(emittedSequences, [
|
||||
"\x1b[104u",
|
||||
"\x1b[104;1:3u",
|
||||
"\x1b[105u",
|
||||
"\x1b[105;1:3u",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mouse Events", () => {
|
||||
it("should handle mouse press event", () => {
|
||||
processInput("\x1b[<0;10;5M");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
|
||||
});
|
||||
|
||||
it("should handle mouse release event", () => {
|
||||
processInput("\x1b[<0;10;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
|
||||
});
|
||||
|
||||
it("should handle mouse move event", () => {
|
||||
processInput("\x1b[<35;20;5m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
|
||||
});
|
||||
|
||||
it("should handle split mouse events", () => {
|
||||
processInput("\x1b[<3");
|
||||
processInput("5;1");
|
||||
processInput("5;");
|
||||
processInput("10m");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
|
||||
});
|
||||
|
||||
it("should handle multiple mouse events", () => {
|
||||
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
|
||||
assert.deepStrictEqual(emittedSequences, [
|
||||
"\x1b[<35;1;1m",
|
||||
"\x1b[<35;2;2m",
|
||||
"\x1b[<35;3;3m",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
|
||||
processInput("\x1b[M abc");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
|
||||
});
|
||||
|
||||
it("should buffer incomplete old-style mouse sequence", () => {
|
||||
processInput("\x1b[M");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
|
||||
|
||||
processInput(" a");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
|
||||
|
||||
processInput("b");
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle empty input", () => {
|
||||
processInput("");
|
||||
// Empty string emits an empty data event
|
||||
assert.deepStrictEqual(emittedSequences, [""]);
|
||||
});
|
||||
|
||||
it("should handle lone escape character with timeout", async () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// After timeout, should emit
|
||||
await wait(15);
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
|
||||
});
|
||||
|
||||
it("should handle lone escape character with explicit flush", () => {
|
||||
processInput("\x1b");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, ["\x1b"]);
|
||||
});
|
||||
|
||||
it("should handle buffer input", () => {
|
||||
processInput(Buffer.from("\x1b[A"));
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
|
||||
});
|
||||
|
||||
it("should handle very long sequences", () => {
|
||||
const longSeq = `\x1b[${"1;".repeat(50)}H`;
|
||||
processInput(longSeq);
|
||||
assert.deepStrictEqual(emittedSequences, [longSeq]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flush", () => {
|
||||
it("should flush incomplete sequences", () => {
|
||||
processInput("\x1b[<35");
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should return empty array if nothing to flush", () => {
|
||||
const flushed = buffer.flush();
|
||||
assert.deepStrictEqual(flushed, []);
|
||||
});
|
||||
|
||||
it("should emit flushed data via timeout", async () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
|
||||
// Wait for timeout to flush
|
||||
await wait(15);
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clear", () => {
|
||||
it("should clear buffered content without emitting", () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
buffer.clear();
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracketed Paste", () => {
|
||||
let emittedPaste: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
buffer = new StdinBuffer({ timeout: 10 });
|
||||
|
||||
// Collect emitted sequences
|
||||
emittedSequences = [];
|
||||
buffer.on("data", (sequence) => {
|
||||
emittedSequences.push(sequence);
|
||||
});
|
||||
|
||||
// Collect paste events
|
||||
emittedPaste = [];
|
||||
buffer.on("paste", (data) => {
|
||||
emittedPaste.push(data);
|
||||
});
|
||||
});
|
||||
|
||||
it("should emit paste event for complete bracketed paste", () => {
|
||||
const pasteStart = "\x1b[200~";
|
||||
const pasteEnd = "\x1b[201~";
|
||||
const content = "hello world";
|
||||
|
||||
processInput(pasteStart + content + pasteEnd);
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
||||
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
|
||||
});
|
||||
|
||||
it("should handle paste arriving in chunks", () => {
|
||||
processInput("\x1b[200~");
|
||||
assert.deepStrictEqual(emittedPaste, []);
|
||||
|
||||
processInput("hello ");
|
||||
assert.deepStrictEqual(emittedPaste, []);
|
||||
|
||||
processInput("world\x1b[201~");
|
||||
assert.deepStrictEqual(emittedPaste, ["hello world"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
|
||||
it("should handle paste with input before and after", () => {
|
||||
processInput("a");
|
||||
processInput("\x1b[200~pasted\x1b[201~");
|
||||
processInput("b");
|
||||
|
||||
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
|
||||
assert.deepStrictEqual(emittedPaste, ["pasted"]);
|
||||
});
|
||||
|
||||
it("should handle paste with newlines", () => {
|
||||
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
|
||||
it("should handle paste with unicode", () => {
|
||||
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
|
||||
|
||||
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Destroy", () => {
|
||||
it("should clear buffer on destroy", () => {
|
||||
processInput("\x1b[<35");
|
||||
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
|
||||
|
||||
buffer.destroy();
|
||||
assert.strictEqual(buffer.getBuffer(), "");
|
||||
});
|
||||
|
||||
it("should clear pending timeouts on destroy", async () => {
|
||||
processInput("\x1b[<35");
|
||||
buffer.destroy();
|
||||
|
||||
// Wait longer than timeout
|
||||
await wait(15);
|
||||
|
||||
// Should not have emitted anything
|
||||
assert.deepStrictEqual(emittedSequences, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
packages/tui/test/terminal-image.test.ts
Normal file
167
packages/tui/test/terminal-image.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Tests for terminal image detection and line handling
|
||||
*/
|
||||
|
||||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { isImageLine } from "../src/terminal-image.js";
|
||||
|
||||
describe("isImageLine", () => {
|
||||
describe("iTerm2 image protocol", () => {
|
||||
it("should detect iTerm2 image escape sequence at start of line", () => {
|
||||
// iTerm2 image escape sequence: ESC ]1337;File=...
|
||||
const iterm2ImageLine =
|
||||
"\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07";
|
||||
assert.strictEqual(isImageLine(iterm2ImageLine), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence with text before it", () => {
|
||||
// Simulating a line that has text then image data (bug scenario)
|
||||
const lineWithTextAndImage =
|
||||
"Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text";
|
||||
assert.strictEqual(isImageLine(lineWithTextAndImage), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence in middle of long line", () => {
|
||||
// Simulate a very long line with image data in the middle
|
||||
const longLineWithImage =
|
||||
"Text before image..." +
|
||||
"\x1b]1337;File=inline=1:verylongbase64data==" +
|
||||
"...text after";
|
||||
assert.strictEqual(isImageLine(longLineWithImage), true);
|
||||
});
|
||||
|
||||
it("should detect iTerm2 image escape sequence at end of line", () => {
|
||||
const lineWithImageAtEnd =
|
||||
"Regular text ending with \x1b]1337;File=inline=1:base64data==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithImageAtEnd), true);
|
||||
});
|
||||
|
||||
it("should detect minimal iTerm2 image escape sequence", () => {
|
||||
const minimalImageLine = "\x1b]1337;File=:\x07";
|
||||
assert.strictEqual(isImageLine(minimalImageLine), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kitty image protocol", () => {
|
||||
it("should detect Kitty image escape sequence at start of line", () => {
|
||||
// Kitty image escape sequence: ESC _G
|
||||
const kittyImageLine =
|
||||
"\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
assert.strictEqual(isImageLine(kittyImageLine), true);
|
||||
});
|
||||
|
||||
it("should detect Kitty image escape sequence with text before it", () => {
|
||||
// Bug scenario: text + image data in same line
|
||||
const lineWithTextAndKittyImage =
|
||||
"Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\";
|
||||
assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true);
|
||||
});
|
||||
|
||||
it("should detect Kitty image escape sequence with padding", () => {
|
||||
// Kitty protocol adds padding to escape sequences
|
||||
const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ ";
|
||||
assert.strictEqual(isImageLine(kittyWithPadding), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bug regression tests", () => {
|
||||
it("should detect image sequences in very long lines (304k+ chars)", () => {
|
||||
// This simulates the crash scenario: a line with 304,401 chars
|
||||
// containing image escape sequences somewhere
|
||||
const base64Char = "A".repeat(100); // 100 chars of base64-like data
|
||||
const imageSequence = "\x1b]1337;File=size=800,600;inline=1:";
|
||||
|
||||
// Build a long line with image sequence
|
||||
const longLine =
|
||||
"Text prefix " +
|
||||
imageSequence +
|
||||
base64Char.repeat(3000) + // ~300,000 chars
|
||||
" suffix";
|
||||
|
||||
assert.strictEqual(longLine.length > 300000, true);
|
||||
assert.strictEqual(isImageLine(longLine), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences when terminal doesn't support images", () => {
|
||||
// The bug occurred when getImageEscapePrefix() returned null
|
||||
// isImageLine should still detect image sequences regardless
|
||||
const lineWithImage =
|
||||
"Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithImage), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences with ANSI codes before them", () => {
|
||||
// Text might have ANSI styling before image data
|
||||
const lineWithAnsiAndImage =
|
||||
"\x1b[31mError output \x1b]1337;File=inline=1:image==\x07";
|
||||
assert.strictEqual(isImageLine(lineWithAnsiAndImage), true);
|
||||
});
|
||||
|
||||
it("should detect image sequences with ANSI codes after them", () => {
|
||||
const lineWithImageAndAnsi =
|
||||
"\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset";
|
||||
assert.strictEqual(isImageLine(lineWithImageAndAnsi), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Negative cases - lines without images", () => {
|
||||
it("should not detect images in plain text lines", () => {
|
||||
const plainText =
|
||||
"This is just a regular text line without any escape sequences";
|
||||
assert.strictEqual(isImageLine(plainText), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with only ANSI codes", () => {
|
||||
const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
|
||||
assert.strictEqual(isImageLine(ansiText), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with cursor movement codes", () => {
|
||||
const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up";
|
||||
assert.strictEqual(isImageLine(cursorCodes), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with partial iTerm2 sequences", () => {
|
||||
// Similar prefix but missing the complete sequence
|
||||
const partialSequence =
|
||||
"Some text with ]1337;File but missing ESC at start";
|
||||
assert.strictEqual(isImageLine(partialSequence), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with partial Kitty sequences", () => {
|
||||
// Similar prefix but missing the complete sequence
|
||||
const partialSequence = "Some text with _G but missing ESC at start";
|
||||
assert.strictEqual(isImageLine(partialSequence), false);
|
||||
});
|
||||
|
||||
it("should not detect images in empty lines", () => {
|
||||
assert.strictEqual(isImageLine(""), false);
|
||||
});
|
||||
|
||||
it("should not detect images in lines with newlines only", () => {
|
||||
assert.strictEqual(isImageLine("\n"), false);
|
||||
assert.strictEqual(isImageLine("\n\n"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed content scenarios", () => {
|
||||
it("should detect images when line has both Kitty and iTerm2 sequences", () => {
|
||||
const mixedLine =
|
||||
"Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07";
|
||||
assert.strictEqual(isImageLine(mixedLine), true);
|
||||
});
|
||||
|
||||
it("should detect image in line with multiple text and image segments", () => {
|
||||
const complexLine =
|
||||
"Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end";
|
||||
assert.strictEqual(isImageLine(complexLine), true);
|
||||
});
|
||||
|
||||
it("should not falsely detect image in line with file path containing keywords", () => {
|
||||
// File path might contain "1337" or "File" but without escape sequences
|
||||
const filePathLine = "/path/to/File_1337_backup/image.jpg";
|
||||
assert.strictEqual(isImageLine(filePathLine), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/tui/test/test-themes.ts
Normal file
42
packages/tui/test/test-themes.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Default themes for TUI tests using chalk
|
||||
*/
|
||||
|
||||
import { Chalk } from "chalk";
|
||||
import type {
|
||||
EditorTheme,
|
||||
MarkdownTheme,
|
||||
SelectListTheme,
|
||||
} from "../src/index.js";
|
||||
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
export const defaultSelectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text: string) => chalk.blue(text),
|
||||
selectedText: (text: string) => chalk.bold(text),
|
||||
description: (text: string) => chalk.dim(text),
|
||||
scrollInfo: (text: string) => chalk.dim(text),
|
||||
noMatch: (text: string) => chalk.dim(text),
|
||||
};
|
||||
|
||||
export const defaultMarkdownTheme: MarkdownTheme = {
|
||||
heading: (text: string) => chalk.bold.cyan(text),
|
||||
link: (text: string) => chalk.blue(text),
|
||||
linkUrl: (text: string) => chalk.dim(text),
|
||||
code: (text: string) => chalk.yellow(text),
|
||||
codeBlock: (text: string) => chalk.green(text),
|
||||
codeBlockBorder: (text: string) => chalk.dim(text),
|
||||
quote: (text: string) => chalk.italic(text),
|
||||
quoteBorder: (text: string) => chalk.dim(text),
|
||||
hr: (text: string) => chalk.dim(text),
|
||||
listBullet: (text: string) => chalk.cyan(text),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
underline: (text: string) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const defaultEditorTheme: EditorTheme = {
|
||||
borderColor: (text: string) => chalk.dim(text),
|
||||
selectList: defaultSelectListTheme,
|
||||
};
|
||||
133
packages/tui/test/truncated-text.test.ts
Normal file
133
packages/tui/test/truncated-text.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Chalk } from "chalk";
|
||||
import { TruncatedText } from "../src/components/truncated-text.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
|
||||
// Force full color in CI so ANSI assertions are deterministic
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
|
||||
describe("TruncatedText component", () => {
|
||||
it("pads output lines to exactly match width", () => {
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(50);
|
||||
|
||||
// Should have exactly one content line (no vertical padding)
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Line should be exactly 50 visible characters
|
||||
const visibleLen = visibleWidth(lines[0]);
|
||||
assert.strictEqual(visibleLen, 50);
|
||||
});
|
||||
|
||||
it("pads output with vertical padding lines to width", () => {
|
||||
const text = new TruncatedText("Hello", 0, 2);
|
||||
const lines = text.render(40);
|
||||
|
||||
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
|
||||
assert.strictEqual(lines.length, 5);
|
||||
|
||||
// All lines should be exactly 40 characters
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(visibleWidth(line), 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long text and pads to width", () => {
|
||||
const longText =
|
||||
"This is a very long piece of text that will definitely exceed the available width";
|
||||
const text = new TruncatedText(longText, 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 30 characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("preserves ANSI codes in output and pads correctly", () => {
|
||||
const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`;
|
||||
const text = new TruncatedText(styledText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 40 visible characters (ANSI codes don't count)
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should preserve the color codes
|
||||
assert.ok(lines[0].includes("\x1b["));
|
||||
});
|
||||
|
||||
it("truncates styled text and adds reset code before ellipsis", () => {
|
||||
const longStyledText = chalk.red(
|
||||
"This is a very long red text that will be truncated",
|
||||
);
|
||||
const text = new TruncatedText(longStyledText, 1, 0);
|
||||
const lines = text.render(20);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 20 visible characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 20);
|
||||
|
||||
// Should contain reset code before ellipsis
|
||||
assert.ok(lines[0].includes("\x1b[0m..."));
|
||||
});
|
||||
|
||||
it("handles text that fits exactly", () => {
|
||||
// With paddingX=1, available width is 30-2=28
|
||||
// "Hello world" is 11 chars, fits comfortably
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should NOT contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(!stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const text = new TruncatedText("", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
});
|
||||
|
||||
it("stops at newline and only shows first line", () => {
|
||||
const multilineText = "First line\nSecond line\nThird line";
|
||||
const text = new TruncatedText(multilineText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should only contain "First line"
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
|
||||
assert.ok(stripped.includes("First line"));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
assert.ok(!stripped.includes("Third line"));
|
||||
});
|
||||
|
||||
it("truncates first line even with newlines in text", () => {
|
||||
const longMultilineText =
|
||||
"This is a very long first line that needs truncation\nSecond line";
|
||||
const text = new TruncatedText(longMultilineText, 1, 0);
|
||||
const lines = text.render(25);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 25);
|
||||
|
||||
// Should contain ellipsis and not second line
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
});
|
||||
});
|
||||
79
packages/tui/test/tui-overlay-style-leak.test.ts
Normal file
79
packages/tui/test/tui-overlay-style-leak.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
|
||||
class StaticLines implements Component {
|
||||
constructor(private readonly lines: string[]) {}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class StaticOverlay implements Component {
|
||||
constructor(private readonly line: string) {}
|
||||
|
||||
render(): string[] {
|
||||
return [this.line];
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function getCellItalic(
|
||||
terminal: VirtualTerminal,
|
||||
row: number,
|
||||
col: number,
|
||||
): number {
|
||||
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
||||
const buffer = xterm.buffer.active;
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
assert.ok(line, `Missing buffer line at row ${row}`);
|
||||
const cell = line.getCell(col);
|
||||
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
||||
return cell.isItalic();
|
||||
}
|
||||
|
||||
async function renderAndFlush(
|
||||
tui: TUI,
|
||||
terminal: VirtualTerminal,
|
||||
): Promise<void> {
|
||||
tui.requestRender(true);
|
||||
await new Promise<void>((resolve) => process.nextTick(resolve));
|
||||
await terminal.flush();
|
||||
}
|
||||
|
||||
describe("TUI overlay compositing", () => {
|
||||
it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => {
|
||||
const width = 20;
|
||||
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
|
||||
|
||||
const terminal = new VirtualTerminal(width, 6);
|
||||
const tui = new TUI(terminal);
|
||||
tui.addChild(new StaticLines([baseLine, "INPUT"]));
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("should not leak styles when overlay slicing drops trailing SGR resets", async () => {
|
||||
const width = 20;
|
||||
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
|
||||
|
||||
const terminal = new VirtualTerminal(width, 6);
|
||||
const tui = new TUI(terminal);
|
||||
tui.addChild(new StaticLines([baseLine, "INPUT"]));
|
||||
|
||||
tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
409
packages/tui/test/tui-render.test.ts
Normal file
409
packages/tui/test/tui-render.test.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { VirtualTerminal } from "./virtual-terminal.js";
|
||||
|
||||
class TestComponent implements Component {
|
||||
lines: string[] = [];
|
||||
render(_width: number): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function getCellItalic(
|
||||
terminal: VirtualTerminal,
|
||||
row: number,
|
||||
col: number,
|
||||
): number {
|
||||
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
|
||||
const buffer = xterm.buffer.active;
|
||||
const line = buffer.getLine(buffer.viewportY + row);
|
||||
assert.ok(line, `Missing buffer line at row ${row}`);
|
||||
const cell = line.getCell(col);
|
||||
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
|
||||
return cell.isItalic();
|
||||
}
|
||||
|
||||
describe("TUI resize handling", () => {
|
||||
it("triggers full re-render when terminal height changes", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize height
|
||||
terminal.resize(40, 15);
|
||||
await terminal.flush();
|
||||
|
||||
// Should have triggered a full redraw
|
||||
assert.ok(
|
||||
tui.fullRedraws > initialRedraws,
|
||||
"Height change should trigger full redraw",
|
||||
);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("Line 0"),
|
||||
"Content preserved after height change",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("triggers full re-render when terminal width changes", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Resize width
|
||||
terminal.resize(60, 10);
|
||||
await terminal.flush();
|
||||
|
||||
// Should have triggered a full redraw
|
||||
assert.ok(
|
||||
tui.fullRedraws > initialRedraws,
|
||||
"Width change should trigger full redraw",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI content shrinkage", () => {
|
||||
it("clears empty rows when content shrinks significantly", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Start with many lines
|
||||
component.lines = [
|
||||
"Line 0",
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
"Line 3",
|
||||
"Line 4",
|
||||
"Line 5",
|
||||
];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
const initialRedraws = tui.fullRedraws;
|
||||
|
||||
// Shrink to fewer lines
|
||||
component.lines = ["Line 0", "Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
// Should have triggered a full redraw to clear empty rows
|
||||
assert.ok(
|
||||
tui.fullRedraws > initialRedraws,
|
||||
"Content shrinkage should trigger full redraw",
|
||||
);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
|
||||
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
|
||||
// Lines below should be empty (cleared)
|
||||
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
|
||||
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles shrink to single line", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Shrink to single line
|
||||
component.lines = ["Only line"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
|
||||
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles shrink to empty", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Shrink to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// All lines should be empty
|
||||
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
|
||||
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI differential rendering", () => {
|
||||
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Initial render: 5 identical lines
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
// cursorRow should be 2 (last line of new content)
|
||||
// Verify by doing another render with a change on line 1
|
||||
component.lines = ["Line 0", "CHANGED", "Line 2"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
// Line 1 should show "CHANGED", proving cursor tracking was correct
|
||||
assert.ok(
|
||||
viewport[1]?.includes("CHANGED"),
|
||||
`Expected "CHANGED" on line 1, got: ${viewport[1]}`,
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when only a middle line changes (spinner case)", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Initial render
|
||||
component.lines = ["Header", "Working...", "Footer"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Simulate spinner animation - only middle line changes
|
||||
const spinnerFrames = ["|", "/", "-", "\\"];
|
||||
for (const frame of spinnerFrames) {
|
||||
component.lines = ["Header", `Working ${frame}`, "Footer"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("Header"),
|
||||
`Header preserved: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[1]?.includes(`Working ${frame}`),
|
||||
`Spinner updated: ${viewport[1]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[2]?.includes("Footer"),
|
||||
`Footer preserved: ${viewport[2]}`,
|
||||
);
|
||||
}
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("resets styles after each rendered line", async () => {
|
||||
const terminal = new VirtualTerminal(20, 6);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["\x1b[3mItalic", "Plain"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when first line changes but rest stays same", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Change only first line
|
||||
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("CHANGED"),
|
||||
`First line changed: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[1]?.includes("Line 1"),
|
||||
`Line 1 preserved: ${viewport[1]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[2]?.includes("Line 2"),
|
||||
`Line 2 preserved: ${viewport[2]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[3]?.includes("Line 3"),
|
||||
`Line 3 preserved: ${viewport[3]}`,
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when last line changes but rest stays same", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Change only last line
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("Line 0"),
|
||||
`Line 0 preserved: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[1]?.includes("Line 1"),
|
||||
`Line 1 preserved: ${viewport[1]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[2]?.includes("Line 2"),
|
||||
`Line 2 preserved: ${viewport[2]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[3]?.includes("CHANGED"),
|
||||
`Last line changed: ${viewport[3]}`,
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders correctly when multiple non-adjacent lines change", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
// Change lines 1 and 3, keep 0, 2, 4 the same
|
||||
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("Line 0"),
|
||||
`Line 0 preserved: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[1]?.includes("CHANGED 1"),
|
||||
`Line 1 changed: ${viewport[1]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[2]?.includes("Line 2"),
|
||||
`Line 2 preserved: ${viewport[2]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[3]?.includes("CHANGED 3"),
|
||||
`Line 3 changed: ${viewport[3]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[4]?.includes("Line 4"),
|
||||
`Line 4 preserved: ${viewport[4]}`,
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("handles transition from content to empty and back to content", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Start with content
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
tui.start();
|
||||
await terminal.flush();
|
||||
|
||||
let viewport = terminal.getViewport();
|
||||
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
|
||||
|
||||
// Clear to empty
|
||||
component.lines = [];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
// Add content back - this should work correctly even after empty state
|
||||
component.lines = ["New Line 0", "New Line 1"];
|
||||
tui.requestRender();
|
||||
await terminal.flush();
|
||||
|
||||
viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[0]?.includes("New Line 0"),
|
||||
`New content rendered: ${viewport[0]}`,
|
||||
);
|
||||
assert.ok(
|
||||
viewport[1]?.includes("New Line 1"),
|
||||
`New content line 1: ${viewport[1]}`,
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
113
packages/tui/test/viewport-overwrite-repro.ts
Normal file
113
packages/tui/test/viewport-overwrite-repro.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* TUI viewport overwrite repro
|
||||
*
|
||||
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
|
||||
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
|
||||
*
|
||||
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
|
||||
* tmux new-session -d -s tui-bug -x 80 -y 12
|
||||
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
|
||||
* tmux attach -t tui-bug
|
||||
*
|
||||
* Expected behavior:
|
||||
* - PRE-TOOL lines remain visible above tool output.
|
||||
* - POST-TOOL lines append after tool output without overwriting earlier content.
|
||||
*
|
||||
* Actual behavior (bug):
|
||||
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
|
||||
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
|
||||
*/
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
class Lines implements Component {
|
||||
private lines: string[] = [];
|
||||
|
||||
set(lines: string[]): void {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
append(lines: string[]): void {
|
||||
this.lines.push(...lines);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.lines.map((line) => {
|
||||
if (line.length > width) return line.slice(0, width);
|
||||
return line.padEnd(width, " ");
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
async function streamLines(
|
||||
buffer: Lines,
|
||||
label: string,
|
||||
count: number,
|
||||
delayMs: number,
|
||||
ui: TUI,
|
||||
): Promise<void> {
|
||||
for (let i = 1; i <= count; i += 1) {
|
||||
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
|
||||
ui.requestRender();
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ui = new TUI(new ProcessTerminal());
|
||||
const buffer = new Lines();
|
||||
ui.addChild(buffer);
|
||||
ui.start();
|
||||
|
||||
const height = ui.terminal.rows;
|
||||
const preCount = height + 8; // Ensure content exceeds viewport
|
||||
const toolCount = height + 12; // Tool output pushes further into scrollback
|
||||
const postCount = 6;
|
||||
|
||||
buffer.set([
|
||||
"TUI viewport overwrite repro",
|
||||
`Viewport rows detected: ${height}`,
|
||||
"(Resize to ~8-12 rows for best repro)",
|
||||
"",
|
||||
"=== PRE-TOOL STREAM ===",
|
||||
]);
|
||||
ui.requestRender();
|
||||
await sleep(300);
|
||||
|
||||
// Phase 1: Stream pre-tool text until viewport is exceeded.
|
||||
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
|
||||
|
||||
// Phase 2: Simulate tool call pause and tool output.
|
||||
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
|
||||
ui.requestRender();
|
||||
await sleep(700);
|
||||
|
||||
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
|
||||
|
||||
// Phase 3: Post-tool streaming. This is where overwrite often appears.
|
||||
buffer.append(["", "=== POST-TOOL STREAM ==="]);
|
||||
ui.requestRender();
|
||||
await sleep(300);
|
||||
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
|
||||
|
||||
// Leave the output visible briefly, then restore terminal state.
|
||||
await sleep(1500);
|
||||
ui.stop();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// Ensure terminal is restored if something goes wrong.
|
||||
try {
|
||||
const ui = new TUI(new ProcessTerminal());
|
||||
ui.stop();
|
||||
} catch {
|
||||
// Ignore restore errors.
|
||||
}
|
||||
process.stderr.write(`${String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
209
packages/tui/test/virtual-terminal.ts
Normal file
209
packages/tui/test/virtual-terminal.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import xterm from "@xterm/headless";
|
||||
import type { Terminal } from "../src/terminal.js";
|
||||
|
||||
// Extract Terminal class from the module
|
||||
const XtermTerminal = xterm.Terminal;
|
||||
|
||||
/**
|
||||
* Virtual terminal for testing using xterm.js for accurate terminal emulation
|
||||
*/
|
||||
export class VirtualTerminal implements Terminal {
|
||||
private xterm: XtermTerminalType;
|
||||
private inputHandler?: (data: string) => void;
|
||||
private resizeHandler?: () => void;
|
||||
private _columns: number;
|
||||
private _rows: number;
|
||||
|
||||
constructor(columns = 80, rows = 24) {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
|
||||
// Create xterm instance with specified dimensions
|
||||
this.xterm = new XtermTerminal({
|
||||
cols: columns,
|
||||
rows: rows,
|
||||
// Disable all interactive features for testing
|
||||
disableStdin: true,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
}
|
||||
|
||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.inputHandler = onInput;
|
||||
this.resizeHandler = onResize;
|
||||
// Enable bracketed paste mode for consistency with ProcessTerminal
|
||||
this.xterm.write("\x1b[?2004h");
|
||||
}
|
||||
|
||||
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
|
||||
// No-op for virtual terminal - no stdin to drain
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Disable bracketed paste mode
|
||||
this.xterm.write("\x1b[?2004l");
|
||||
this.inputHandler = undefined;
|
||||
this.resizeHandler = undefined;
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
this.xterm.write(data);
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this._columns;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
// Virtual terminal always reports Kitty protocol as active for testing
|
||||
return true;
|
||||
}
|
||||
|
||||
moveBy(lines: number): void {
|
||||
if (lines > 0) {
|
||||
// Move down
|
||||
this.xterm.write(`\x1b[${lines}B`);
|
||||
} else if (lines < 0) {
|
||||
// Move up
|
||||
this.xterm.write(`\x1b[${-lines}A`);
|
||||
}
|
||||
// lines === 0: no movement
|
||||
}
|
||||
|
||||
hideCursor(): void {
|
||||
this.xterm.write("\x1b[?25l");
|
||||
}
|
||||
|
||||
showCursor(): void {
|
||||
this.xterm.write("\x1b[?25h");
|
||||
}
|
||||
|
||||
clearLine(): void {
|
||||
this.xterm.write("\x1b[K");
|
||||
}
|
||||
|
||||
clearFromCursor(): void {
|
||||
this.xterm.write("\x1b[J");
|
||||
}
|
||||
|
||||
clearScreen(): void {
|
||||
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
// OSC 0;title BEL - set terminal window title
|
||||
this.xterm.write(`\x1b]0;${title}\x07`);
|
||||
}
|
||||
|
||||
// Test-specific methods not in Terminal interface
|
||||
|
||||
/**
|
||||
* Simulate keyboard input
|
||||
*/
|
||||
sendInput(data: string): void {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the terminal
|
||||
*/
|
||||
resize(columns: number, rows: number): void {
|
||||
this._columns = columns;
|
||||
this._rows = rows;
|
||||
this.xterm.resize(columns, rows);
|
||||
if (this.resizeHandler) {
|
||||
this.resizeHandler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
// Write an empty string to ensure all previous writes are flushed
|
||||
return new Promise<void>((resolve) => {
|
||||
this.xterm.write("", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and get viewport - convenience method for tests
|
||||
*/
|
||||
async flushAndGetViewport(): Promise<string[]> {
|
||||
await this.flush();
|
||||
return this.getViewport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible viewport (what's currently on screen)
|
||||
* Note: You should use getViewportAfterWrite() for testing after writing data
|
||||
*/
|
||||
getViewport(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get only the visible lines (viewport)
|
||||
for (let i = 0; i < this.xterm.rows; i++) {
|
||||
const line = buffer.getLine(buffer.viewportY + i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entire scroll buffer
|
||||
*/
|
||||
getScrollBuffer(): string[] {
|
||||
const lines: string[] = [];
|
||||
const buffer = this.xterm.buffer.active;
|
||||
|
||||
// Get all lines in the buffer (including scrollback)
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
} else {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the terminal viewport
|
||||
*/
|
||||
clear(): void {
|
||||
this.xterm.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the terminal completely
|
||||
*/
|
||||
reset(): void {
|
||||
this.xterm.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor position
|
||||
*/
|
||||
getCursorPosition(): { x: number; y: number } {
|
||||
const buffer = this.xterm.buffer.active;
|
||||
return {
|
||||
x: buffer.cursorX,
|
||||
y: buffer.cursorY,
|
||||
};
|
||||
}
|
||||
}
|
||||
158
packages/tui/test/wrap-ansi.test.ts
Normal file
158
packages/tui/test/wrap-ansi.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
|
||||
|
||||
describe("wrapTextWithAnsi", () => {
|
||||
describe("underline styling", () => {
|
||||
it("should not apply underline style before the styled text", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const url = "https://example.com/very/long/path/that/will/wrap";
|
||||
const text = `read this thread ${underlineOn}${url}${underlineOff}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 40);
|
||||
|
||||
// First line should NOT contain underline code - it's just "read this thread"
|
||||
assert.strictEqual(wrapped[0], "read this thread");
|
||||
|
||||
// Second line should start with underline, have URL content
|
||||
assert.strictEqual(wrapped[1].startsWith(underlineOn), true);
|
||||
assert.ok(wrapped[1].includes("https://"));
|
||||
});
|
||||
|
||||
it("should not have whitespace before underline reset code", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
|
||||
|
||||
assert.ok(!wrapped[0].includes(` ${underlineOff}`));
|
||||
});
|
||||
|
||||
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const url =
|
||||
"https://example.com/very/long/path/that/will/definitely/wrap";
|
||||
const text = `prefix ${underlineOn}${url}${underlineOff} suffix`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 30);
|
||||
|
||||
// Middle lines (with underlined content) should end with underline-off, not full reset
|
||||
// Line 1 and 2 contain underlined URL parts
|
||||
for (let i = 1; i < wrapped.length - 1; i++) {
|
||||
const line = wrapped[i];
|
||||
if (line.includes(underlineOn)) {
|
||||
// Should end with underline off, NOT full reset
|
||||
assert.strictEqual(line.endsWith(underlineOff), true);
|
||||
assert.strictEqual(line.endsWith("\x1b[0m"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("background color preservation", () => {
|
||||
it("should preserve background color across wrapped lines without full reset", () => {
|
||||
const bgBlue = "\x1b[44m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${bgBlue}hello world this is blue background text${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 15);
|
||||
|
||||
// Each line should have background color
|
||||
for (const line of wrapped) {
|
||||
assert.ok(line.includes(bgBlue));
|
||||
}
|
||||
|
||||
// Middle lines should NOT end with full reset (kills background for padding)
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
|
||||
const underlineOn = "\x1b[4m";
|
||||
const underlineOff = "\x1b[24m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 20);
|
||||
|
||||
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
|
||||
for (const line of wrapped) {
|
||||
const hasBgColor =
|
||||
line.includes("[41m") ||
|
||||
line.includes(";41m") ||
|
||||
line.includes("[41;");
|
||||
assert.ok(hasBgColor);
|
||||
}
|
||||
|
||||
// Lines with underlined content should use underline-off at end, not full reset
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
const line = wrapped[i];
|
||||
// If this line has underline on, it should end with underline off (not full reset)
|
||||
if (
|
||||
(line.includes("[4m") ||
|
||||
line.includes("[4;") ||
|
||||
line.includes(";4m")) &&
|
||||
!line.includes(underlineOff)
|
||||
) {
|
||||
assert.strictEqual(line.endsWith(underlineOff), true);
|
||||
assert.strictEqual(line.endsWith("\x1b[0m"), false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic wrapping", () => {
|
||||
it("should wrap plain text correctly", () => {
|
||||
const text = "hello world this is a test";
|
||||
const wrapped = wrapTextWithAnsi(text, 10);
|
||||
|
||||
assert.ok(wrapped.length > 1);
|
||||
for (const line of wrapped) {
|
||||
assert.ok(visibleWidth(line) <= 10);
|
||||
}
|
||||
});
|
||||
|
||||
it("should ignore OSC 133 semantic markers in visible width", () => {
|
||||
const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
|
||||
assert.strictEqual(visibleWidth(text), 5);
|
||||
});
|
||||
|
||||
it("should ignore OSC sequences terminated with ST in visible width", () => {
|
||||
const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\";
|
||||
assert.strictEqual(visibleWidth(text), 5);
|
||||
});
|
||||
|
||||
it("should treat isolated regional indicators as width 2", () => {
|
||||
assert.strictEqual(visibleWidth("🇨"), 2);
|
||||
assert.strictEqual(visibleWidth("🇨🇳"), 2);
|
||||
});
|
||||
|
||||
it("should truncate trailing whitespace that exceeds width", () => {
|
||||
const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1);
|
||||
assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);
|
||||
});
|
||||
|
||||
it("should preserve color codes across wraps", () => {
|
||||
const red = "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
const text = `${red}hello world this is red${reset}`;
|
||||
|
||||
const wrapped = wrapTextWithAnsi(text, 10);
|
||||
|
||||
// Each continuation line should start with red code
|
||||
for (let i = 1; i < wrapped.length; i++) {
|
||||
assert.strictEqual(wrapped[i].startsWith(red), true);
|
||||
}
|
||||
|
||||
// Middle lines should not end with full reset
|
||||
for (let i = 0; i < wrapped.length - 1; i++) {
|
||||
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue