chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -67,6 +67,7 @@ class NullTerminal implements Terminal {
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
}
const userMarkdown = [

View File

@@ -0,0 +1,98 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { CombinedAutocompleteProvider } from "../src/autocomplete.ts";
describe("CombinedAutocompleteProvider slash-command filter", () => {
const commands = [
{ name: "skill:deep-research", description: "Multi-agent deep research" },
{ name: "skill:research-idea", description: "Refine a raw idea into a falsifiable seed" },
{ name: "skill:to-sidecar", description: "Route work to a sidecar" },
{ name: "skill:brainstorm", description: "Generate ideas" },
{ name: "model", description: "Select the active model" },
];
async function suggestionsFor(prefix: string): Promise<string[]> {
const provider = new CombinedAutocompleteProvider(commands, process.cwd());
const line = `/${prefix}`;
const result = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
assert.ok(result, `expected suggestions for "/${prefix}"`);
return result.items.map((item) => item.value);
}
it("ranks skill:research-idea first for query 'idea'", async () => {
const items = await suggestionsFor("idea");
assert.equal(items[0], "skill:research-idea");
assert.ok(items.indexOf("skill:deep-research") > items.indexOf("skill:research-idea"));
});
it("keeps ordinary slash commands matching", async () => {
const items = await suggestionsFor("mod");
assert.ok(items.includes("model"));
});
it("completes commands after leading whitespace and preserves it", async () => {
const provider = new CombinedAutocompleteProvider([{ name: "model" }], process.cwd());
for (const [line, expected] of [
[" /", " /model "],
[" /mod", " /model "],
["\t/mod", "\t/model "],
] as const) {
const result = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
assert.ok(result);
assert.equal(result.prefix, line.trimStart());
assert.deepStrictEqual(
result.items.map((item) => item.value),
["model"],
);
const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix);
assert.equal(applied.lines[0], expected);
assert.equal(applied.cursorCol, expected.length);
}
});
it("completes command arguments after leading whitespace", async () => {
const provider = new CombinedAutocompleteProvider(
[
{
name: "model",
getArgumentCompletions: (prefix: string) => {
assert.equal(prefix, "son");
return [{ value: "sonnet", label: "sonnet" }];
},
},
],
process.cwd(),
);
const line = " /model son";
const result = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
assert.ok(result);
assert.equal(result.prefix, "son");
const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix);
assert.equal(applied.lines[0], " /model sonnet");
});
it("keeps explicit skill: queries working", async () => {
const items = await suggestionsFor("skill:side");
assert.ok(items.includes("skill:to-sidecar"));
});
// Regression test for #9944.
it("lists skills while typing the skill prefix", async () => {
const items = await suggestionsFor("skill");
assert.deepStrictEqual(
items.filter((item) => item.startsWith("skill:")),
commands.filter((command) => command.name.startsWith("skill:")).map((command) => command.name),
);
});
it("keeps fuzzy skill-prefix shorthand working", async () => {
const items = await suggestionsFor("skbra");
assert.ok(items.includes("skill:brainstorm"));
});
});

View File

@@ -147,6 +147,94 @@ describe("CombinedAutocompleteProvider", () => {
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
});
test("recognizes @ after CJK punctuation without consuming the preceding text", async () => {
setupFolder(baseDir, { files: { "README.md": "readme" } });
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
for (const before of ["查看,", "\u3000", ...",.:;!?()[]{}“”‘’…—。、「」『』《》【】"]) {
for (const force of [false, true]) {
const line = `${before}@REA`;
const result = await getSuggestions(provider, [line], 0, line.length, force);
assert.ok(result, line);
assert.strictEqual(result.prefix, "@REA");
assert.deepStrictEqual(
result.items.map((item) => item.value),
["@README.md"],
);
const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `${before}@README.md `);
assert.strictEqual(applied.cursorCol, applied.lines[0]!.length);
}
}
});
test("recognizes @ after opening wrappers like ( and backticks", async () => {
setupFolder(baseDir, { files: { "README.md": "readme" } });
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
for (const before of ["(", "see (", "[", "`", "<", "{"]) {
const line = `${before}@REA`;
const result = await getSuggestions(provider, [line], 0, line.length);
assert.ok(result, line);
assert.strictEqual(result.prefix, "@REA");
const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `${before}@README.md `);
}
const embedded = "foo(@REA";
assert.strictEqual(await getSuggestions(provider, [embedded], 0, embedded.length), null);
});
test("preserves CJK characters and embedded @ in attachment paths", async () => {
setupFolder(baseDir, {
files: { "文档/说明.md": "text", "文档@备份/说明.md": "backup" },
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
for (const before of ["", "查看,"]) {
for (const directory of ["文档", "文档@备份"]) {
const prefix = `@${directory}/说`;
const line = before + prefix;
const result = await getSuggestions(provider, [line], 0, line.length);
assert.ok(result, line);
assert.strictEqual(result.prefix, prefix);
assert.deepStrictEqual(
result.items.map((item) => item.value),
[`@${directory}/说明.md`],
);
}
}
});
test("completes quoted CJK attachments after prose without losing path segments or quotes", async () => {
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
for (const separator of [" ", "\u3000", ",", "。"]) {
const directory = `我的${separator}文档`;
setupFolder(baseDir, {
files: { [`${directory}/说明.md`]: "text", "文档/说明.md": "not the quoted path" },
});
const line = `查看:@"${directory}/说"后文`;
const cursorCol = line.indexOf('"后文');
const result = await getSuggestions(provider, [line], 0, cursorCol);
assert.ok(result);
assert.strictEqual(result.prefix, `@"${directory}/说`);
assert.deepStrictEqual(
result.items.map((item) => item.value),
[`@"${directory}/说明.md"`],
);
const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `查看:@"${directory}/说明.md" 后文`);
assert.strictEqual(applied.cursorCol, `查看:@"${directory}/说明.md" `.length);
}
});
test("does not interpret email addresses or @ after ASCII or CJK letters as attachment prefixes", async () => {
setupFolder(baseDir, { files: { "README.md": "readme", "example.com": "text" } });
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
for (const before of ["user", "查看", "あ", "カ", "한", "ㄅ", "𠮷", "か\u3099", "禰\u{e0100}", "々", "A"]) {
for (const name of ["REA", "example.com"]) {
const line = `${before}@${name}`;
assert.strictEqual(await getSuggestions(provider, [line], 0, line.length), null, line);
}
}
});
test("matches file with extension in query", async () => {
setupFolder(baseDir, {
files: {
@@ -300,20 +388,21 @@ describe("CombinedAutocompleteProvider", () => {
);
});
test("quotes paths with spaces for @ suggestions", async () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
test("quotes paths containing whitespace or CJK punctuation for @ suggestions", async () => {
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@my";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/"'));
for (const separator of [" ", "\u3000", ",", "。"]) {
const directory = `my${separator}folder`;
setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } });
const line = "@my";
const result = await getSuggestions(provider, [line], 0, line.length);
assert.ok(result);
const item = result.items.find((entry) => entry.value === `@"${directory}/"`);
assert.ok(item, directory);
const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix);
const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol);
assert.strictEqual(continued?.prefix, `@"${directory}/`);
assert.ok(continued?.items.some((entry) => entry.value === `@"${directory}/test.txt"`));
}
});
test("includes hidden paths but excludes .git", async () => {
@@ -474,6 +563,153 @@ describe("CombinedAutocompleteProvider", () => {
rmSync(baseDir, { recursive: true, force: true });
});
test("completes Chinese path prefixes after whitespace or CJK punctuation on Tab", async () => {
setupFolder(baseDir, { files: { "说明.md": "file", "文档/说明.md": "nested file" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
const completions = [
{ prefix: "说", value: "说明.md" },
{ prefix: "文", value: "文档/" },
{ prefix: "文档/说", value: "文档/说明.md" },
{ prefix: "./文档/说", value: "./文档/说明.md" },
];
if (process.platform !== "win32") {
completions.push({ prefix: `${baseDir}/文档/说`, value: `${baseDir}/文档/说明.md` });
}
for (const separator of " \t\u3000\u00a0,:;。!?(「《") {
for (const { prefix, value } of completions) {
const before = `查看𠮷${separator}`;
const line = `${before}${prefix} 后文`;
const cursorCol = before.length + prefix.length;
const result = await getSuggestions(provider, [line], 0, cursorCol, true);
assert.ok(result, line);
assert.strictEqual(result.prefix, prefix);
assert.deepStrictEqual(
result.items.map((item) => item.value),
[value],
);
const applied = provider.applyCompletion([line], 0, cursorCol, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `${before}${value} 后文`);
assert.strictEqual(applied.cursorCol, before.length + value.length);
}
}
});
test("treats unquoted separators as boundaries even when a matching literal path exists", async () => {
setupFolder(baseDir, { files: { "归档/说明.md": "other" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
for (const separator of [" ", "\u3000", ",", "。"]) {
const directory = `资料${separator}归档`;
setupFolder(baseDir, { files: { [`${directory}/说明.md`]: "archive" } });
for (const marker of ["", "@"]) {
const line = `${marker}${directory}/说`;
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result, line);
assert.strictEqual(result.prefix, "归档/说");
assert.deepStrictEqual(
result.items.map((item) => item.value),
["归档/说明.md"],
);
}
const quoted = `查看,"${directory}/说"后文`;
const cursorCol = quoted.indexOf('"后文');
const result = await getSuggestions(provider, [quoted], 0, cursorCol, true);
assert.ok(result);
assert.strictEqual(result.prefix, `"${directory}/说`);
assert.deepStrictEqual(
result.items.map((item) => item.value),
[`"${directory}/说明.md"`],
);
const applied = provider.applyCompletion([quoted], 0, cursorCol, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `查看,"${directory}/说明.md"后文`);
const missing = `查看,"不存在${separator}归档/说`;
assert.strictEqual(await getSuggestions(provider, [missing], 0, missing.length, true), null);
}
});
test("handles an empty prefix after whitespace or CJK punctuation consistently", async () => {
setupFolder(baseDir, { files: { "说明.md": "text" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
for (const separator of [" ", "\t", "\u3000", ",", "。"]) {
for (const force of [false, true]) {
const line = `查看${separator}`;
const result = await getSuggestions(provider, [line], 0, line.length, force);
assert.ok(result, line);
assert.strictEqual(result.prefix, "");
assert.deepStrictEqual(
result.items.map((item) => item.value),
["说明.md"],
);
}
}
assert.strictEqual(await getSuggestions(provider, [""], 0, 0), null);
});
test("completes paths after opening wrappers like ( [ { < and backticks", async () => {
setupFolder(baseDir, { files: { "src/main.ts": "x" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
for (const wrapper of ["(", "[", "{", "<", "`", "((", "(`"]) {
for (const prefix of ["src/ma", "./src/ma"]) {
const before = `see ${wrapper}`;
const line = `${before}${prefix}`;
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result, line);
assert.strictEqual(result.prefix, prefix);
const value = prefix.replace("src/ma", "src/main.ts");
assert.deepStrictEqual(
result.items.map((item) => item.value),
[value],
);
const applied = provider.applyCompletion([line], 0, line.length, result.items[0]!, result.prefix);
assert.strictEqual(applied.lines[0], `${before}${value}`);
}
}
});
test("completes quoted paths after opening wrappers", async () => {
setupFolder(baseDir, { files: { "my dir/main.ts": "x" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = 'see ("my dir/ma';
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result);
assert.strictEqual(result.prefix, '"my dir/ma');
assert.deepStrictEqual(
result.items.map((item) => item.value),
['"my dir/main.ts"'],
);
});
test("keeps wrappers that are closed inside the path", async () => {
setupFolder(baseDir, { files: { "[slug]/page.tsx": "x", "(group)/layout.tsx": "x" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
for (const [prefix, value] of [
["[slug]/pa", "[slug]/page.tsx"],
["(group)/la", "(group)/layout.tsx"],
["./[slug]/pa", "./[slug]/page.tsx"],
]) {
const line = `see ${prefix}`;
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result, line);
assert.strictEqual(result.prefix, prefix);
assert.deepStrictEqual(
result.items.map((item) => item.value),
[value],
);
}
});
test("preserves CJK characters in unprefixed Tab completions", async () => {
setupFolder(baseDir, { files: { "文档/说明.md": "text" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "文档/说";
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result);
assert.strictEqual(result.prefix, line);
assert.deepStrictEqual(
result.items.map((item) => item.value),
["文档/说明.md"],
);
});
test("preserves ./ prefix when completing paths", async () => {
setupFolder(baseDir, {
files: {
@@ -520,21 +756,35 @@ describe("CombinedAutocompleteProvider", () => {
rmSync(baseDir, { recursive: true, force: true });
});
test("quotes paths with spaces for direct completion", async () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
test("quotes paths containing whitespace or CJK punctuation for direct completion", async () => {
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "my";
const result = await getSuggestions(provider, [line], 0, line.length, true);
for (const separator of [" ", "\u3000", ",", "。"]) {
const directory = `my${separator}folder`;
setupFolder(baseDir, { files: { [`${directory}/test.txt`]: "content" } });
const line = "my";
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.ok(result);
const item = result.items.find((entry) => entry.value === `"${directory}/"`);
assert.ok(item, directory);
const applied = provider.applyCompletion([line], 0, line.length, item, result.prefix);
const continued = await getSuggestions(provider, applied.lines, 0, applied.cursorCol, true);
assert.strictEqual(continued?.prefix, `"${directory}/`);
assert.deepStrictEqual(
continued?.items.map((entry) => entry.value),
[`"${directory}/test.txt"`],
);
}
});
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("keeps quoted directories before files", async () => {
setupFolder(baseDir, { dirs: ["z folder", "z,folder"], files: { "a.txt": "text" } });
const provider = new CombinedAutocompleteProvider([], baseDir);
const result = await getSuggestions(provider, [""], 0, 0, true);
assert.ok(result);
assert.deepStrictEqual(
result.items.map((item) => item.label.endsWith("/")),
[true, true, false],
);
});
test("continues completion inside quoted paths", async () => {

View File

@@ -0,0 +1,47 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import {
colorToHex,
colorToOkhsl,
colorToRgb,
indexedColor,
okhslColor,
oklchColor,
parseColor,
rgbColor,
styleText,
} from "../src/index.ts";
describe("colors", () => {
it("parses hex and OKLCH colors and rejects everything else", () => {
assert.deepStrictEqual(parseColor("#abc"), { kind: "rgb", r: 170, g: 187, b: 204 });
assert.deepStrictEqual(parseColor("oklch(62% 0.1 200)"), { kind: "oklch", l: 0.62, c: 0.1, h: 200 });
assert.throws(() => parseColor(""), /Invalid color value/);
assert.throws(() => parseColor("red"), /Invalid color value/);
});
it("gamut-maps OKLCH to sRGB, including the lightness limits", () => {
assert.deepStrictEqual(colorToRgb(oklchColor(0.627955, 0.257683, 29.2339)), { r: 255, g: 0, b: 0 });
assert.deepStrictEqual(colorToRgb(oklchColor(1, 0.3, 150)), { r: 255, g: 255, b: 255 });
assert.deepStrictEqual(colorToRgb(oklchColor(0, 0.3, 150)), { r: 0, g: 0, b: 0 });
});
it("parses OKHSL colors and round-trips them", () => {
// Full saturation at the red cusp is pure sRGB red.
assert.deepStrictEqual(parseColor("okhsl(29.23 100% 56.8%)"), rgbColor(255, 0, 0));
assert.deepStrictEqual(parseColor("OKHSL(250deg 60% 55%)"), okhslColor(250, 0.6, 0.55));
assert.throws(() => parseColor("okhsl(250 160% 55%)"), /s must be between 0 and 1/);
for (const hex of ["#4f8eb3", "#20242a", "#f8f9fa"]) {
const { h, s, l } = colorToOkhsl(parseColor(hex));
assert.strictEqual(colorToHex(okhslColor(h, s, l)), hex);
}
});
it("styles text and closes sequences in reverse order", () => {
assert.strictEqual(
styleText("Ready", { fg: rgbColor(18, 52, 86), bg: indexedColor(9), bold: true, italic: true }, "truecolor"),
"\x1b[38;2;18;52;86m\x1b[48;5;9m\x1b[1m\x1b[3mReady\x1b[23m\x1b[22m\x1b[49m\x1b[39m",
);
assert.match(styleText("Ready", { fg: rgbColor(18, 52, 86) }, "256color"), /^\x1b\[38;5;\d+mReady\x1b\[39m$/);
});
});

View File

@@ -1,11 +1,14 @@
import assert from "node:assert";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { stripVTControlCharacters } from "node:util";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts";
import { Editor, wordWrapLine } from "../src/components/editor.ts";
import type { TUI } from "../src/tui.ts";
import { FAKE_CURSOR_START, renderFakeCursor, type TUI } from "../src/tui.ts";
import { TuiMainScreen } from "../src/tui-main-screen.ts";
import { visibleWidth } from "../src/utils.ts";
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
import { defaultEditorTheme } from "./test-themes.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
@@ -797,7 +800,7 @@ describe("Editor component", () => {
}
// Verify content split correctly
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
assert.strictEqual(contentLines.length, 2);
assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns
assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding)
@@ -829,7 +832,7 @@ describe("Editor component", () => {
// The cursor (reverse video space) should be visible
const contentLine = lines[1]!;
assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor");
assert.ok(contentLine.includes(FAKE_CURSOR_START), "Should have fake cursor");
// Line should still be correct width
assert.strictEqual(visibleWidth(contentLine), width);
@@ -860,7 +863,7 @@ describe("Editor component", () => {
let lines = editor.render(width + paddingX);
let contentLines = lines.slice(1, -1);
assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap");
assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line");
assert.ok(contentLines[0]!.endsWith(renderFakeCursor(" ")), "Cursor should be at end of line");
// Type 1 more → text wraps to second line
editor.handleInput("a");
@@ -880,7 +883,7 @@ describe("Editor component", () => {
const lines = editor.render(width);
// Get content lines (between borders)
const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim());
const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim());
// Should NOT break mid-word
// Line 1 should end with a complete word
@@ -2129,6 +2132,218 @@ describe("Editor component", () => {
});
describe("Autocomplete", () => {
it("triggers and debounces symbol completion after CJK punctuation", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const before of [
"查看,",
"\u3000",
...",.:;!?()[]{}“”‘’…—。、「」『』《》【】",
"(",
"see (",
"`",
"[",
]) {
for (const trigger of ["@", "#", "$", "-"]) {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const requests: string[] = [];
editor.setAutocompleteProvider({
triggerCharacters: ["$", "-"],
getSuggestions: async (lines, cursorLine, cursorCol) => {
requests.push(lines[cursorLine]!.slice(0, cursorCol));
return null;
},
applyCompletion,
});
editor.setText(before);
editor.handleInput(trigger);
t.mock.timers.tick(19);
await flushAutocomplete();
assert.deepStrictEqual(requests, []);
t.mock.timers.tick(1);
await flushAutocomplete();
assert.deepStrictEqual(requests, [before + trigger]);
editor.handleInput("r");
editor.handleInput("e");
t.mock.timers.tick(19);
await flushAutocomplete();
assert.strictEqual(requests.length, 1);
t.mock.timers.tick(1);
await flushAutocomplete();
assert.deepStrictEqual(requests, [before + trigger, `${before}${trigger}re`]);
}
}
});
it("does not auto-trigger after CJK letters or for unprefixed paths", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const editor = new Editor(createTestTUI(), defaultEditorTheme);
let requests = 0;
editor.setAutocompleteProvider({
getSuggestions: async () => {
requests++;
return null;
},
applyCompletion,
});
for (const text of [
"user@example.com",
"张三@example.com",
"查看@src",
"あ@src",
"カ@src",
"한@src",
"ㄅ@src",
"𠮷@src",
"か\u3099@src",
"禰\u{e0100}@src",
"々@src",
"A@src",
"文档@备份",
"prefix#123",
"foo(@src",
"问题#123",
"查看,/path/",
"查看,./文档/",
"src/index.ts",
"./文档/说明.md",
"文档/说明.md",
"查看src/index.ts",
]) {
editor.setText("");
for (const char of text) editor.handleInput(char);
t.mock.timers.tick(20);
await flushAutocomplete();
assert.strictEqual(requests, 0, text);
}
});
it("requests path completion after CJK punctuation only on Tab", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const requests: Array<{ text: string; force: boolean | undefined }> = [];
editor.setAutocompleteProvider({
getSuggestions: async (lines, cursorLine, cursorCol, options) => {
requests.push({ text: lines[cursorLine]!.slice(0, cursorCol), force: options.force });
return null;
},
applyCompletion,
});
const text = "查看,/path/";
for (const char of text) editor.handleInput(char);
t.mock.timers.tick(20);
await flushAutocomplete();
assert.deepStrictEqual(requests, []);
editor.handleInput("\t");
await flushAutocomplete();
assert.deepStrictEqual(requests, [{ text, force: true }]);
});
it("completes Chinese path prefixes after whitespace or CJK punctuation with Tab", async (t) => {
const baseDir = mkdtempSync(join(tmpdir(), "pi-editor-autocomplete-"));
t.after(() => rmSync(baseDir, { recursive: true, force: true }));
mkdirSync(join(baseDir, "文档"));
writeFileSync(join(baseDir, "文档", "说明.md"), "text");
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([], baseDir));
for (const separator of [" ", "\t", "\u3000", "\u00a0", ",", "。"]) {
editor.setText(`查看${separator}`);
const before = editor.getText();
editor.handleInput("文");
editor.handleInput("\t");
await flushAutocomplete();
assert.strictEqual(editor.getText(), `${before}文档/`);
editor.handleInput("说");
editor.handleInput("\t");
await flushAutocomplete();
assert.strictEqual(editor.getText(), `${before}文档/说明.md`);
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length });
}
});
it("ends unquoted trigger and debounce contexts at whitespace or CJK punctuation", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const separator of [" ", "\u3000", ",", "。"]) {
for (const trigger of ["@", "#", "$"]) {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const requests: string[] = [];
const prefix = `${trigger}src`;
editor.setAutocompleteProvider({
triggerCharacters: ["$"],
getSuggestions: async (lines, cursorLine, cursorCol) => {
const text = lines[cursorLine]!.slice(0, cursorCol);
requests.push(text);
return text === prefix ? { prefix, items: [{ value: `${prefix}/`, label: "src/" }] } : null;
},
applyCompletion,
});
editor.setText(`${trigger}sr`);
editor.handleInput("c");
t.mock.timers.tick(20);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
editor.handleInput(separator);
await flushAutocomplete();
assert.deepStrictEqual(requests, [prefix, prefix + separator]);
assert.strictEqual(editor.isShowingAutocomplete(), false);
editor.handleInput("文");
t.mock.timers.tick(20);
await flushAutocomplete();
assert.deepStrictEqual(requests, [prefix, prefix + separator]);
}
}
});
it("re-triggers CJK path completion after accepting directories and deleting", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const provider = new CombinedAutocompleteProvider([], process.cwd());
for (const directory of ["文档", "我的 文档", "资料,归档"]) {
const quoted = directory !== "文档";
const initial = quoted ? `@"${directory.slice(0, 2)}` : "@文";
const directoryValue = quoted ? `@"${directory}/"` : `@${directory}/`;
const fileValue = quoted ? `@"${directory}/说明.md"` : `@${directory}/说明.md`;
const filePrefix = quoted ? `@"${directory}/说` : `@${directory}/说`;
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.setAutocompleteProvider({
getSuggestions: async (lines, cursorLine, cursorCol) => {
const before = lines[cursorLine]!.slice(0, cursorCol);
const prefix = before.slice(before.indexOf("@"));
if (prefix === initial) {
return { prefix, items: [{ value: directoryValue, label: `${directory}/` }] };
}
return prefix === filePrefix ? { prefix, items: [{ value: fileValue, label: "说明.md" }] } : null;
},
applyCompletion: (...args) => provider.applyCompletion(...args),
});
editor.setText(`查看:${initial}`);
editor.handleInput("\t");
await flushAutocomplete();
assert.strictEqual(editor.getText(), `查看:${directoryValue}`);
assert.strictEqual(editor.isShowingAutocomplete(), false);
editor.handleInput("说");
t.mock.timers.tick(20);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
for (const deletion of ["\x7f", "\x1b[3~"]) {
editor.handleInput("错");
t.mock.timers.tick(20);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), false);
if (deletion === "\x1b[3~") editor.handleInput("\x1b[D");
editor.handleInput(deletion);
t.mock.timers.tick(20);
await flushAutocomplete();
assert.strictEqual(editor.isShowingAutocomplete(), true);
}
editor.handleInput("\t");
assert.strictEqual(editor.getText(), `查看:${fileValue} `);
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: editor.getText().length });
}
});
it("auto-applies single force-file suggestion without showing menu", async () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);

View File

@@ -0,0 +1,31 @@
const { createHash } = require("node:crypto");
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
(async () => {
let ticks = 0;
const timer = setInterval(() => ticks++, 10);
const started = performance.now();
try {
const helper = require(process.argv[2]);
const pending = helper[process.argv[3]]();
assert.ok(pending instanceof Promise, "Native reads must return promises");
const value = await pending;
const bytes = typeof value === "string" || Buffer.isBuffer(value) ? Buffer.from(value) : undefined;
console.log(JSON.stringify({
ok: true,
value: value === null || (typeof value === "string" && value.length <= 100) ? value : undefined,
unavailable: value === undefined || undefined,
length: bytes?.length,
hash: bytes && createHash("sha256").update(bytes).digest("hex"),
}));
} catch (error) {
console.log(JSON.stringify({ ok: false, error: String(error) }));
} finally {
clearInterval(timer);
if (performance.now() - started > 200) assert.ok(ticks > 5, "Clipboard I/O must not block the JS event loop");
// Check before Node exits: process exit must not hide a leaked child or zombie.
assert.equal(readFileSync(`/proc/self/task/${process.pid}/children`, "utf8").trim(), "");
}
})().catch((error) => { console.error(error); process.exitCode = 1; });

View File

@@ -0,0 +1,64 @@
#include <assert.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
static atomic_int allocations, reads;
static atomic_bool released;
static void* tracked_calloc(size_t count, size_t size) {
void* pointer = calloc(count, size);
if (pointer) atomic_fetch_add(&allocations, 1);
return pointer;
}
static void tracked_free(void* pointer) {
if (pointer) atomic_fetch_sub(&allocations, 1);
free(pointer);
}
#define calloc tracked_calloc
#define free tracked_free
#include "../../native/linux/src/clipboard-worker.h"
#undef calloc
#undef free
// Exercise the production executor with a controlled blocking callback, without X11.
static void read_clipboard(clipboard_job* job) {
job->data = tracked_calloc(5, 1);
assert(job->data);
memcpy(job->data, "text", 4);
job->length = 4;
job->format = job->operation == CLIPBOARD_IMAGE ? CLIPBOARD_BUFFER : CLIPBOARD_UTF8;
atomic_fetch_add(&reads, 1);
while (!atomic_load(&released)) {
struct timespec delay = {.tv_nsec = 1000000};
nanosleep(&delay, 0);
}
}
static napi_value release_reader(napi_env env, napi_callback_info info) {
(void)info;
atomic_store(&released, true);
return undefined_value(env);
}
static napi_value get_state(napi_env env, napi_callback_info info) {
(void)info;
char state[80];
pthread_mutex_lock(&worker_mutex);
snprintf(state, sizeof(state), "%d,%d,%d", atomic_load(&reads), atomic_load(&allocations), worker_busy);
pthread_mutex_unlock(&worker_mutex);
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
napi_value result = 0;
create_string(env, state, NAPI_AUTO_LENGTH, &result);
return result;
}
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
set_function_export(env, exports, "release", release_reader);
set_function_export(env, exports, "state", get_state);
return exports;
}

View File

@@ -0,0 +1,66 @@
const assert = require("node:assert/strict");
const { once } = require("node:events");
const { readFile } = require("node:fs/promises");
const { setTimeout: delay } = require("node:timers/promises");
const { isMainThread, parentPort, Worker, workerData } = require("node:worker_threads");
async function waitFor(predicate) {
const deadline = performance.now() + 5000;
while (!predicate()) {
assert.ok(performance.now() < deadline, "Timed out waiting for the private reader");
await delay(10);
}
}
(async () => {
if (!isMainThread) {
const helper = require(workerData);
void helper.getText();
await waitFor(() => helper.state().startsWith("1,"));
parentPort.postMessage("reading");
return;
}
const [addon, mode] = process.argv.slice(2);
if (mode === "worker-exit") {
// The originating environment unloads while its private callback still waits.
const worker = new Worker(__filename, { workerData: addon });
await once(worker, "message");
await worker.terminate();
const helper = require(addon);
assert.equal(helper.state(), "1,1,1", "The detached operation survives environment teardown");
helper.release();
await waitFor(() => helper.state() === "1,0,0");
assert.equal(await helper.getText(), "text");
console.log("passed");
return;
}
const helper = require(addon);
const started = performance.now();
const pending = helper.getText();
await waitFor(() => helper.state().startsWith("1,"));
if (mode === "process-exit") {
assert.equal(await pending, undefined);
console.log("passed");
return; // The private callback never finishes; Node must still exit naturally.
}
const concurrentStarted = performance.now();
const concurrent = Array.from({ length: 20 }, (_, index) => helper[index % 2 ? "getText" : "getImage"]());
// With UV_THREADPOOL_SIZE=2, a second blocked native task would delay this read.
await readFile(__filename);
assert.deepEqual(await Promise.all(concurrent), Array(20).fill(undefined));
assert.ok(performance.now() - concurrentStarted < 1500, "Concurrent reads and filesystem I/O must remain available");
assert.equal(await pending, undefined);
assert.ok(performance.now() - started < 4500, "The shared worker must leave its bounded wait");
assert.equal(helper.state(), "1,1,1", "Only one private reader may remain blocked");
assert.equal(await helper.getImage(), undefined);
assert.equal(helper.state(), "1,1,1", "Another request must not start another private reader");
helper.release();
await waitFor(() => helper.state() === "1,0,0");
assert.equal(await helper.getText(), "text");
assert.deepEqual(await helper.getImage(), Buffer.from("text"));
assert.equal(helper.state(), "3,0,0", "Late and successful results must all be freed");
console.log("passed");
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,177 @@
#include <xcb/xcb.h>
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Track only allocations made by the clipboard implementation, not libxcb.
// This makes the partial-transfer leak regression deterministic, unlike RSS.
static void* allocations[64];
static size_t sizes[64];
static size_t allocated_bytes;
static size_t peak_bytes;
static void* tracked_realloc(void* pointer, size_t size) {
size_t index = 0;
while (index < 64 && allocations[index] != pointer) index++;
assert(index < 64);
void* result = realloc(pointer, size);
if (result) {
allocated_bytes = allocated_bytes - sizes[index] + size;
sizes[index] = size;
allocations[index] = result;
if (allocated_bytes > peak_bytes) peak_bytes = allocated_bytes;
}
return result;
}
static void tracked_free(void* pointer) {
for (size_t index = 0; pointer && index < 64; index++) {
if (allocations[index] == pointer) {
allocated_bytes -= sizes[index];
sizes[index] = 0;
allocations[index] = 0;
break;
}
}
free(pointer);
}
#define realloc tracked_realloc
#define free tracked_free
#include "../../native/linux/src/linux-platform-x11.c"
#undef realloc
#undef free
int main(int argc, char** argv) {
if (argc != 2) return 1;
if (strcmp(argv[1], "metadata") == 0) {
xcb_atom_t atom = XCB_ATOM_STRING;
property_data chunk = {
.data = (unsigned char*)&atom, .length = sizeof(atom),
.items = 1, .format = 32, .type = XCB_ATOM_ATOM,
};
property_data result = {0};
assert(append_property(&result, &chunk));
assert(append_property(&result, &chunk));
assert(result.length == 2 * sizeof(atom) && result.items == 2);
chunk.format = 8;
chunk.items = sizeof(atom);
assert(!append_property(&result, &chunk));
chunk.format = 32;
chunk.items = 1;
chunk.type = XCB_ATOM_STRING;
assert(!append_property(&result, &chunk));
chunk.type = XCB_ATOM_ATOM;
chunk.length--;
assert(!append_property(&result, &chunk));
assert(result.length == 2 * sizeof(atom) && result.items == 2);
// The terminating chunk must agree with the preceding metadata too.
chunk.length = chunk.items = 0;
chunk.format = 8;
assert(!append_property(&result, &chunk));
chunk.format = 32;
assert(append_property(&result, &chunk));
tracked_free(result.data);
assert(allocated_bytes == 0);
puts("validated");
return 0;
}
if (strcmp(argv[1], "text") == 0 || strcmp(argv[1], "image") == 0) {
x11_clipboard clipboard;
assert(open_clipboard(&clipboard));
property_data result = {0};
bool received = read_selection(&clipboard, strcmp(argv[1], "image") == 0, &result);
close_clipboard(&clipboard);
assert(!received);
assert(!result.data && !result.length);
assert(peak_bytes >= 8192); // Both the received chunk and accumulated data were allocated.
assert(allocated_bytes == 0);
puts("clean");
return 0;
}
x11_clipboard clipboard;
assert(open_clipboard(&clipboard));
xcb_connection_t* connection = clipboard.connection;
xcb_atom_t text = intern_atom(&clipboard, "UTF8_STRING");
xcb_atom_t image = intern_atom(&clipboard, "image/png");
xcb_set_selection_owner(connection, clipboard.window, clipboard.clipboard, XCB_CURRENT_TIME);
// Wait for the server to process selection ownership before reporting ready.
assert(intern_atom(&clipboard, "READY") != XCB_NONE);
puts("ready");
fflush(stdout);
xcb_window_t requestor = XCB_NONE;
xcb_atom_t property = XCB_NONE;
xcb_atom_t target = XCB_NONE;
bool sent_chunk = false;
xcb_generic_event_t* event;
while ((event = xcb_wait_for_event(connection))) {
uint8_t type = event->response_type & 0x7f;
if (type == XCB_SELECTION_REQUEST) {
xcb_selection_request_event_t* request = (xcb_selection_request_event_t*)event;
puts("requested");
fflush(stdout);
if (strcmp(argv[1], "idle") == 0) {
free(event);
continue;
}
xcb_atom_t response_property = request->property;
if (strcmp(argv[1], "direct-text") == 0) {
if (request->target == text) {
const char value[] = "café 日本語";
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, request->requestor,
request->property, text, 8, sizeof(value) - 1, value);
} else {
response_property = XCB_NONE;
}
} else if (request->target == clipboard.targets) {
xcb_atom_t targets[] = {text, image};
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, request->requestor,
request->property, XCB_ATOM_ATOM, 32, 2, targets);
} else {
requestor = request->requestor;
property = request->property;
target = request->target;
uint32_t mask = XCB_EVENT_MASK_PROPERTY_CHANGE;
xcb_change_window_attributes(connection, requestor, XCB_CW_EVENT_MASK, &mask);
uint32_t length = 8192;
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor,
property, clipboard.incr, 32, 1, &length);
}
union {
xcb_selection_notify_event_t selection;
char bytes[32];
} notification = { .selection = {
.response_type = XCB_SELECTION_NOTIFY,
.time = request->time,
.requestor = request->requestor,
.selection = request->selection,
.target = request->target,
.property = response_property,
} };
xcb_send_event(connection, false, request->requestor, XCB_EVENT_MASK_NO_EVENT, notification.bytes);
xcb_flush(connection);
} else if (type == XCB_PROPERTY_NOTIFY) {
xcb_property_notify_event_t* notification = (xcb_property_notify_event_t*)event;
if (notification->window == requestor && notification->atom == property &&
notification->state == XCB_PROPERTY_DELETE) {
if (!sent_chunk) {
unsigned char chunk[4096] = {0};
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor, property, target, 8, sizeof(chunk), chunk);
sent_chunk = true;
} else if (strcmp(argv[1], "invalid") == 0) {
// Reject the terminating chunk after allocating the preceding data.
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor, property, XCB_ATOM_ATOM, 32, 0, 0);
} // Otherwise never finish the transfer; the reader must time out.
xcb_flush(connection);
}
}
free(event);
}
close_clipboard(&clipboard);
}

View File

@@ -17,7 +17,7 @@ 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]");
console.error("Usage: node test/image-test.ts [path-to-image.png]");
process.exit(1);
}

View File

@@ -14,8 +14,8 @@ describe("KeybindingsManager", () => {
it("binds modified and unmodified editor viewport navigation", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+home", "ctrl+a"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+end", "ctrl+e"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+a"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+e"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageUp"), ["pageUp", "ctrl+pageUp"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]);
});
@@ -42,8 +42,8 @@ describe("KeybindingsManager", () => {
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["ctrl+home"]);
assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["ctrl+end"]);
});
it("does not evict selector confirm when input submit is rebound", () => {

View File

@@ -134,6 +134,16 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
});
it("should match shifted symbols by their produced character", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "+"), true);
assert.strictEqual(matchesKey("\x1b[61:43;2u", "shift+="), true);
assert.strictEqual(parseKey("\x1b[61:43;2u"), "+");
assert.strictEqual(matchesKey("\x1b[61:43;6u", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[61:43;6u"), "ctrl++");
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
@@ -265,6 +275,8 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
assert.strictEqual(matchesKey("\x1b[27;6;43~", "ctrl++"), true);
assert.strictEqual(parseKey("\x1b[27;6;43~"), "ctrl++");
});
it("should match xterm modifyOtherKeys digit combos", () => {
@@ -600,6 +612,8 @@ describe("parseKey", () => {
assert.strictEqual(parseKey(" "), "space");
assert.strictEqual(parseKey("1"), "1");
assert.strictEqual(matchesKey("1", "1"), true);
assert.strictEqual(parseKey("+"), "+");
assert.strictEqual(matchesKey("+", "+"), true);
});
it("should parse arrow keys", () => {

View File

@@ -53,7 +53,7 @@ describe("renderLatex", () => {
[String.raw`\ge 2`, "≥ 2"],
[String.raw`\ge 3`, "≥ 3"],
["1", "1"],
[String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"],
[String.raw`\mathrm{diag}(-1/2,1,1),\quad F_{\rm intrinsic}(\lambda)`, "diag(-1/2,1,1), F_intrinsic(λ)"],
["4+3xy", "4+3xy"],
]);
});
@@ -230,7 +230,7 @@ c_n
\Psi^\ast\Psi, & 0<x<L,\\
0, & \text{otherwise}.
\end{cases}`,
"Ψ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎧ Ψ^∗Ψ if 0 < x < L,\n⎩ 0 otherwise.",
`${" ".repeat(97)}⎧ Ψ^∗Ψ if 0 < x < L,\nΨ(x,t) = ∑ₙ₌₁^∞ cₙ √(2/L) sin((nπ x)/L)_(spatial eigenmode) exp(-(iℏ n²π²)/(2mL²)t), |Ψ(x,t)|² = ⎨\n${" ".repeat(97)}⎩ 0 otherwise.`,
],
[String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, "x = (-b±√(b²-4ac))/(2a)"],
[String.raw`\int_0^\infty e^{-x^2}\,dx=\frac{\sqrt{\pi}}{2}`, "∫₀^∞ e^(-x²) dx = (√π)/2"],
@@ -346,7 +346,12 @@ c_n
renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`),
"x́+ỳ+widehat(xyz)+overrightarrow(AB)",
);
assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x");
assert.strictEqual(
renderLatex(
String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}+{\rm roman}+{\bf bold}+{\it italic}+{\sf sans}+{\tt mono}+{\cal calligraphic}+{\sl slanted}`,
),
"hello+world+x+roman+bold+italic+sans+mono+calligraphic+slanted",
);
});
it("renders additional display environments", () => {
@@ -362,8 +367,8 @@ c_n
it("uses natural case conditions and aligns matrix columns", () => {
assert.strictEqual(
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`),
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise",
renderLatex(String.raw`f(x)=\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`),
" ⎧ a if x < 0\nf(x) = ⎨ b if x = 0\n ⎩ c otherwise",
);
assert.strictEqual(
renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`),
@@ -449,10 +454,10 @@ R\left(\frac{\pi}{4}\right)
assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0");
});
it("uses the middle brace for intermediate case rows", () => {
it("centers even case rows around a middle brace", () => {
assert.strictEqual(
renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`),
"⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0",
renderLatex(String.raw`f(x) = \begin{cases} x^{2} & x \geq 0 \\ -x & x < 0 \end{cases}`),
" ⎧ x² if x ≥ 0\nf(x) = ⎨\n ⎩ -x if x < 0",
);
});
@@ -487,9 +492,14 @@ R\left(\frac{\pi}{4}\right)
}
});
it("keeps fractions linear in scripts and text-style fractions", () => {
assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)");
assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2");
it("lays out unsupported and nested scripts while keeping script fractions linear", () => {
assert.strictEqual(
renderLatex(String.raw`\partial_tU_2(t,0)=Aj_*(1-t)^{-A-1}.\qquad x^{n^2}+x_{i_j}`, {
display: true,
}),
" 2\n -A-1 n\n∂ₜU₂(t,0) = Aj (1-t) . x +x\n * i\n j",
);
assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}+\tfrac{1}{2}`, { display: true }), "e^(1/2)+1/2");
});
it("returns undefined for unsupported commands", () => {

View File

@@ -785,7 +785,7 @@ describe("Markdown component", () => {
describe("LaTeX math", () => {
it("renders inline dollar and parenthesis delimiters", () => {
const markdown = new Markdown(
String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).`,
String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, $\rightarrow$, and \(s \to \infty\).`,
0,
0,
defaultMarkdownTheme,
@@ -793,7 +793,7 @@ describe("Markdown component", () => {
const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd());
assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞."]);
assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, →, and s → ∞."]);
});
it("renders display dollar delimiters without Markdown escape corruption", () => {
@@ -936,7 +936,13 @@ A=
it("allows LaTeX rendering to be disabled", () => {
const markdown = new Markdown(
String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`,
String.raw`$$
\widetilde Y_{sf}
=
(1-w_{sf})\mu_{sf}^{\mathrm{MAR}}
$$
Inline \(A_{sf}\)`,
0,
0,
defaultMarkdownTheme,
@@ -948,7 +954,15 @@ A=
assert.deepStrictEqual(
markdown.render(80).map((line) => stripAnsi(line).trimEnd()),
[String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`],
[
"$$",
String.raw`\widetilde Y_{sf}`,
"=",
String.raw`(1-w_{sf})\mu_{sf}^{\mathrm{MAR}}`,
"$$",
"",
String.raw`Inline \(A_{sf}\)`,
],
);
});

View File

@@ -59,6 +59,29 @@ class InputOverlay extends Container {
}
}
/** A settings submenu that routes keys to its nested list, like the coding agent's theme submenu. */
class SubmenuHost extends Container {
readonly list = new SelectList(
[
{ value: "first", label: "First" },
{ value: "second", label: "Second" },
],
5,
selectTheme,
);
constructor(done: (value?: string) => void) {
super();
this.list.onSelect = (item) => done(item.value);
this.list.onCancel = () => done();
this.addChild(this.list);
}
handleInput(data: string): void {
this.list.handleInput(data);
}
}
describe("mouse-aware components", () => {
it("positions a single-line input cursor on press", () => {
const input = new Input();
@@ -202,6 +225,45 @@ describe("mouse-aware components", () => {
tui.stop();
});
it("keeps a settings list focused when a click in its submenu closes the submenu", async () => {
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal);
const changes: Array<{ id: string; value: string }> = [];
const list = new SettingsList(
[
{ id: "theme", label: "Theme", currentValue: "first", submenu: (_value, done) => new SubmenuHost(done) },
{ id: "other", label: "Other", currentValue: "off", values: ["off", "on"] },
],
5,
settingsTheme,
(id, value) => changes.push({ id, value }),
() => {},
);
tui.addChild(list);
tui.setFocus(list);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\r");
await terminal.waitForRender();
// Press and release on the submenu's second row selects it and closes the submenu.
terminal.sendInput("\x1b[<0;3;2M");
terminal.sendInput("\x1b[<0;3;2m");
await terminal.waitForRender();
assert.deepStrictEqual(changes, [{ id: "theme", value: "second" }]);
assert.strictEqual(tui.getFocusedComponent(), list);
// Keys reach the visible list again instead of the closed submenu.
terminal.sendInput("\x1b[B");
terminal.sendInput("\r");
await terminal.waitForRender();
assert.deepStrictEqual(changes, [
{ id: "theme", value: "second" },
{ id: "other", value: "on" },
]);
tui.stop();
});
it("positions and focuses the multiline editor through alternate-screen dispatch", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);

View File

@@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, execFile, execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const exec = promisify(execFile);
const fixtures = fileURLToPath(new URL("./fixtures/", import.meta.url));
const native = fileURLToPath(new URL("../native/linux/", import.meta.url));
const supported = process.platform === "linux" && ["arm64", "x64"].includes(process.arch);
const dependenciesAvailable =
supported &&
spawnSync("sh", ["-c", "command -v cc && command -v Xvfb && command -v xclip && pkg-config --exists xcb"], {
stdio: "ignore",
}).status === 0;
interface ReaderResult {
ok: boolean;
value?: string | null;
unavailable?: boolean;
length?: number;
hash?: string;
error?: string;
}
async function startServer(
t: TestContext,
command: string,
args: string[],
env: NodeJS.ProcessEnv,
): Promise<{ child: ChildProcessWithoutNullStreams; ready: string }> {
const child = spawn(command, args, { env, stdio: "pipe" });
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGKILL");
await exited;
}
});
let stderr = "";
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
const ready = await new Promise<string>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => reject(new Error(`${command} exited with ${code}: ${stderr}`)));
child.stdout.once("data", (data: Buffer) => resolve(data.toString().trim()));
});
return { child, ready };
}
async function readClipboard(method: string, env: NodeJS.ProcessEnv): Promise<ReaderResult> {
const { stdout } = await exec(
process.execPath,
[
join(fixtures, "clipboard-reader.cjs"),
join(native, "prebuilds", `linux-${process.arch}`, "linux-platform-x11.node"),
method,
],
{ env, timeout: 6000 },
);
return JSON.parse(stdout) as ReaderResult;
}
describe("native Linux clipboard", { skip: !dependenciesAvailable, timeout: 60000 }, () => {
let directory: string;
async function startX11(t: TestContext) {
const { child, ready } = await startServer(
t,
"Xvfb",
["-displayfd", "1", "-screen", "0", "640x480x24", "-nolisten", "tcp"],
process.env,
);
return { child, env: { ...process.env, DISPLAY: `:${ready}` } };
}
before(() => {
directory = mkdtempSync(join(tmpdir(), "pi-clipboard-test-"));
const flags = ["-std=c11", "-D_POSIX_C_SOURCE=200809L", "-Wall", "-Wextra", "-Werror", "-pthread"];
execFileSync("cc", [
...flags,
join(fixtures, "clipboard-x11-test.c"),
"-lxcb",
"-ldl",
"-o",
join(directory, "x11-test"),
]);
execFileSync("cc", [
...flags,
"-shared",
"-fPIC",
"-nostdlib", // Match the prebuild's unversioned system-library imports.
...(process.arch === "arm64" ? ["-mno-outline-atomics"] : []), // Inline the fixture's allocation counters.
"-Wl,-z,nodelete",
join(fixtures, "clipboard-worker-test.c"),
"-ldl",
"-o",
join(directory, "worker-test.node"),
]);
});
after(() => {
if (directory) rmSync(directory, { recursive: true, force: true });
});
it("validates X11 incremental property metadata before accumulating bytes", () => {
assert.equal(execFileSync(join(directory, "x11-test"), ["metadata"], { encoding: "utf8" }).trim(), "validated");
});
for (const mode of ["concurrent", "process-exit", "worker-exit"]) {
it(`bounds private clipboard work and preserves cleanup: ${mode}`, async () => {
const { stdout } = await exec(
process.execPath,
[join(fixtures, "clipboard-worker-test.cjs"), join(directory, "worker-test.node"), mode],
{ env: { ...process.env, UV_THREADPOOL_SIZE: "2" }, timeout: 10000 },
);
assert.equal(stdout.trim(), "passed");
});
}
for (const method of ["getText", "getImage"]) {
it(`keeps JS responsive during stalled X11 connection setup: ${method}`, async (t) => {
const sockets = new Set<Socket>();
let connections = 0;
const server = createServer((socket) => {
connections++;
sockets.add(socket);
socket.on("data", () => {}); // Accept setup bytes without replying.
// libxcb owns setup blocking; release it after checking the JS timer.
const timer = setTimeout(() => socket.destroy(), 300);
t.after(() => clearTimeout(timer));
socket.on("close", () => sockets.delete(socket));
});
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string" && address.port > 6000);
const env = { ...process.env, DISPLAY: `127.0.0.1:${address.port - 6000}` };
const started = performance.now();
const result = await readClipboard(method, env);
assert.ok(performance.now() - started < 3500, "The worker must return after the connection closes");
assert.deepEqual(result, { ok: true, unavailable: true });
assert.ok(connections > 0, "The helper must reach the stalled X11 server");
assert.equal(sockets.size, 0, "The failed connection must be closed");
});
}
it("decodes X11 STRING as Latin-1", async (t) => {
const { env } = await startX11(t);
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", "STRING"], {
env,
input: Buffer.from("café £ÿ", "latin1"),
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
const result = await readClipboard("getText", env);
assert.equal(result.ok, true, result.error);
assert.equal(result.value, "café £ÿ");
});
it("reads UTF8_STRING when X11 TARGETS is refused", async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["direct-text"], env);
const result = await readClipboard("getText", env);
assert.equal(result.ok, true, result.error);
assert.equal(result.value, "café 日本語");
});
it("returns null for empty X11 clipboards and text-only selections", async (t) => {
const { env } = await startX11(t);
assert.deepEqual(await readClipboard("getText", env), { ok: true, value: null });
assert.deepEqual(await readClipboard("getImage", env), { ok: true, value: null });
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", "UTF8_STRING"], {
env,
input: "text only",
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
assert.deepEqual(await readClipboard("getImage", env), { ok: true, value: null });
});
it("preserves X11 Unicode and incremental text/image transfers", async (t) => {
const { env } = await startX11(t);
for (const [target, method, bytes] of [
["UTF8_STRING", "getText", Buffer.from("café 日本語")],
["UTF8_STRING", "getText", Buffer.alloc(4 * 1024 * 1024, 120)],
["image/png", "getImage", Buffer.alloc(4 * 1024 * 1024, 123)],
] as const) {
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", target], {
env,
input: bytes,
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
const result = await readClipboard(method, env);
assert.equal(result.ok, true, result.error);
assert.equal(result.length, bytes.length);
assert.equal(result.hash, createHash("sha256").update(bytes).digest("hex"));
}
});
for (const mode of ["disconnect", "timeout"]) {
it(`reports X11 transfer ${mode} as an exception, not an unavailable display`, async (t) => {
const { child, env } = await startX11(t);
const owner = await startServer(t, join(directory, "x11-test"), ["idle"], env);
const requested = once(owner.child.stdout, "data");
const started = performance.now();
const result = readClipboard("getText", env);
await requested;
if (mode === "disconnect") child.kill("SIGKILL");
const failure = await result;
assert.equal(failure.ok, false);
assert.match(failure.error ?? "", /X11 clipboard/);
assert.ok(performance.now() - started < 3500, "The transfer must share the 2-second deadline");
});
}
for (const content of ["text", "image"]) {
it(`frees partially received X11 ${content} after invalid metadata`, async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["invalid"], env);
const { stdout } = await exec(join(directory, "x11-test"), [content], { env, timeout: 6000 });
assert.equal(stdout.trim(), "clean");
});
it(`times out stalled X11 ${content} after a partial transfer without blocking JS`, async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["partial"], env);
const started = performance.now();
const result = await readClipboard(content === "text" ? "getText" : "getImage", env);
assert.equal(result.ok, false);
assert.match(result.error ?? "", /X11 clipboard/);
assert.ok(performance.now() - started < 3500);
});
}
});

View File

@@ -8,7 +8,7 @@ describe("getNativeModuleCandidates", () => {
it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => {
const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui");
const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js");
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node");
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-platform.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,
@@ -26,7 +26,7 @@ describe("getNativeModuleCandidates", () => {
it("keeps standalone binary fallbacks when the TUI package is unavailable", () => {
const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js");
const execPath = resolve("virtual", "pi", "pi.exe");
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node");
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-platform.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire, Module } from "node:module";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { getNativeClipboard, getNativePlatformHelper } from "../src/native-platform.ts";
test("Linux ARM64 prebuild supports 64 KB system pages", () => {
const binary = readFileSync(
new URL("../native/linux/prebuilds/linux-arm64/linux-platform-x11.node", import.meta.url),
);
assert.equal(binary.subarray(0, 4).toString(), "\x7fELF");
const offset = Number(binary.readBigUInt64LE(32));
const entrySize = binary.readUInt16LE(54);
const count = binary.readUInt16LE(56);
let loadSegments = 0;
for (let index = 0; index < count; index++) {
const entry = offset + index * entrySize;
if (binary.readUInt32LE(entry) !== 1) continue;
loadSegments++;
const fileOffset = binary.readBigUInt64LE(entry + 8);
const address = binary.readBigUInt64LE(entry + 16);
assert.equal((address - fileOffset) % 65536n, 0n);
assert.ok(binary.readBigUInt64LE(entry + 48) >= 65536n);
}
assert.ok(loadSegments > 0);
});
// Opt in on a Windows test desktop: this replaces the system clipboard contents.
test(
"writes Windows clipboard text through the native helper without command fallbacks",
{ skip: process.platform !== "win32" || process.env.PI_TEST_NATIVE_CLIPBOARD !== "1" },
async () => {
const clipboard = getNativeClipboard();
assert.ok(clipboard?.setText);
for (const text of ["clipboard café 日本語", "", "second write"]) {
await clipboard.setText(text);
assert.equal(await clipboard.getText(), text);
assert.equal(await clipboard.getImage(), null);
}
},
);
test(
"uses the native platform helper directly as the clipboard API",
{ skip: !["darwin", "win32"].includes(process.platform) || !["arm64", "x64"].includes(process.arch) },
() => {
const clipboard = getNativeClipboard();
assert.ok(clipboard);
assert.equal(typeof clipboard.getText, "function");
assert.equal(typeof clipboard.getImage, "function");
assert.equal(typeof clipboard.setText, "function");
assert.equal(clipboard, getNativePlatformHelper());
assert.equal(clipboard, getNativeClipboard());
},
);
test("Linux loads X11 lazily and rechecks DISPLAY", { skip: !["arm64", "x64"].includes(process.arch) }, async (t) => {
const require = createRequire(import.meta.url);
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
const display = process.env.DISPLAY;
const waylandDisplay = process.env.WAYLAND_DISPLAY;
const modulePath = fileURLToPath(
new URL(`../native/linux/prebuilds/linux-${process.arch}/linux-platform-x11.node`, import.meta.url),
);
const previous = require.cache[modulePath];
t.after(() => {
Object.defineProperty(process, "platform", platform);
if (display === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = display;
if (waylandDisplay === undefined) delete process.env.WAYLAND_DISPLAY;
else process.env.WAYLAND_DISPLAY = waylandDisplay;
if (previous) require.cache[modulePath] = previous;
else delete require.cache[modulePath];
});
Object.defineProperty(process, "platform", { value: "linux" });
delete process.env.DISPLAY;
process.env.WAYLAND_DISPLAY = "wayland-0";
assert.equal(getNativeClipboard(), undefined); // Wayland paste uses wl-paste.
let available = false;
const helper = {
getText: t.mock.fn(async () => (available ? "X11" : undefined)),
getImage: t.mock.fn(async () => null),
};
const module = new Module(modulePath);
module.exports = helper;
require.cache[modulePath] = module;
process.env.DISPLAY = ":0";
const clipboard = getNativeClipboard()!;
assert.equal(clipboard, helper);
assert.equal(helper.getText.mock.callCount(), 0);
assert.equal(await clipboard.getText(), undefined);
available = true;
assert.equal(await clipboard.getText(), "X11");
assert.equal(await clipboard.getImage(), null);
delete process.env.DISPLAY;
assert.equal(getNativeClipboard(), undefined);
process.env.DISPLAY = ":1";
assert.equal(getNativeClipboard(), clipboard);
});

View File

@@ -22,6 +22,20 @@ class StaticOverlay implements Component {
invalidate(): void {}
}
class CursorTrackingTerminal extends VirtualTerminal {
cursorVisible = true;
override hideCursor(): void {
this.cursorVisible = false;
super.hideCursor();
}
override showCursor(): void {
this.cursorVisible = true;
super.showCursor();
}
}
class EmptyContent implements Component {
render(): string[] {
return [];
@@ -538,4 +552,31 @@ describe("TUI overlay options", () => {
tui.stop();
});
});
// https://github.com/earendil-works/pi/issues/10026
describe("hiding after stop", () => {
it("hideOverlay() leaves the cursor visible", () => {
const terminal = new CursorTrackingTerminal(80, 24);
const tui: TUI = new TuiMainScreen(terminal);
tui.start();
tui.showOverlay(new StaticOverlay(["OVERLAY"]), { nonCapturing: true });
tui.stop();
tui.hideOverlay();
assert.strictEqual(terminal.cursorVisible, true);
});
it("overlay handle hide() leaves the cursor visible", () => {
const terminal = new CursorTrackingTerminal(80, 24);
const tui: TUI = new TuiMainScreen(terminal);
tui.start();
const handle = tui.showOverlay(new StaticOverlay(["OVERLAY"]), { nonCapturing: true });
tui.stop();
handle.hide();
assert.strictEqual(terminal.cursorVisible, true);
});
});
});

View File

@@ -0,0 +1,51 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { formatProgramStatus, isProgramStatusReply } from "../src/program-status.ts";
function decodeMessage(sequence: string): string | undefined {
const message = /:msg=([A-Za-z0-9+/=]*)/.exec(sequence)?.[1];
return message === undefined ? undefined : Buffer.from(message, "base64").toString("utf8");
}
// #10607
describe("formatProgramStatus", () => {
it("encodes state, app, kind, and a base64 message", () => {
assert.equal(
formatProgramStatus({ state: "blocked", app: "pi", kind: "permission", message: "Allow bash?" }),
`\x1b]7501;state=blocked:app=pi:kind=permission:msg=${Buffer.from("Allow bash?").toString("base64")}\x1b\\`,
);
assert.equal(formatProgramStatus({ state: "clear" }), "\x1b]7501;state=clear\x1b\\");
});
it("omits kind outside blocked, invalid app names, and empty messages", () => {
assert.equal(
formatProgramStatus({ state: "working", app: "my app", kind: "auth", message: " \n " }),
"\x1b]7501;state=working\x1b\\",
);
assert.match(formatProgramStatus({ state: "idle", app: "a".repeat(32) }), /:app=a{32}\x1b/);
assert.doesNotMatch(formatProgramStatus({ state: "idle", app: "a".repeat(33) }), /app=/);
});
it("replaces control characters, which make terminals discard the report", () => {
const sequence = formatProgramStatus({ state: "error", message: "first\nsecond\x1b[31m\u009bthird\t" });
assert.equal(decodeMessage(sequence), "first second [31m third");
});
it("cuts long messages at a UTF-8 boundary within the spec limits", () => {
const sequence = formatProgramStatus({ state: "working", app: "pi", message: "é".repeat(2000) });
const message = decodeMessage(sequence)!;
assert.equal(message, "é".repeat(1024));
assert.ok(Buffer.byteLength(message) <= 2048);
assert.ok(sequence.length <= 4096);
});
});
describe("isProgramStatusReply", () => {
it("accepts the query echo with either terminator and future pairs", () => {
assert.equal(isProgramStatusReply("\x1b]7501;?\x1b\\"), true);
assert.equal(isProgramStatusReply("\x1b]7501;?\x07"), true);
assert.equal(isProgramStatusReply("\x1b]7501;?version=2\x1b\\"), true);
assert.equal(isProgramStatusReply("\x1b]7501;state=idle\x1b\\"), false);
assert.equal(isProgramStatusReply("\x1b]11;rgb:0000/0000/0000\x07"), false);
});
});

View File

@@ -0,0 +1,17 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { sliceByColumn } from "../src/utils.ts";
// https://github.com/earendil-works/pi/issues/10169
describe("sliceByColumn ANSI order regression", () => {
it("keeps a reset at the slice start after earlier style codes", () => {
const line = "\x1b[32mfoo\x1b[39m bar";
assert.strictEqual(sliceByColumn(line, 3, 4, true), "\x1b[32m\x1b[39m bar");
});
it("does not leak color into text after a highlighted token", () => {
const line = "Another \x1b[35malpha\x1b[39m line with \x1b[35mbeta\x1b[39m later.";
const after = sliceByColumn(line, 13, 100, true);
assert.strictEqual(after, "\x1b[35m\x1b[39m line with \x1b[35mbeta\x1b[39m later.");
});
});

View File

@@ -57,6 +57,7 @@ class NullTerminal implements Terminal {
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
}
/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */

View File

@@ -6,7 +6,7 @@
*/
import assert from "node:assert";
import { beforeEach, describe, it } from "node:test";
import { afterEach, beforeEach, describe, it } from "node:test";
import { matchesKey } from "../src/keys.ts";
import { StdinBuffer } from "../src/stdin-buffer.ts";
@@ -24,6 +24,8 @@ describe("StdinBuffer", () => {
});
});
afterEach(() => buffer.destroy());
// Helper to process data through the buffer
function processInput(data: string | Buffer): void {
buffer.process(data);
@@ -192,6 +194,133 @@ describe("StdinBuffer", () => {
});
});
describe("Terminal Reply Strings", () => {
const replies = [
{ header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:eaea/6c6c/6d6d\x07" }, // Ghostty's Ayu Light palette color 1.
{ header: "\x1b]10;", reply: "\x1b]10;rgb:ffff/ffff/ffff\x1b\\" },
{ header: "\x1b]11;", reply: "\x1b]11;rgb:0000/0000/0000\x07" },
{ header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:6464/6464/4d4d\x07" }, // Another reported color-reply tail.
{ header: "\x1bP>|", reply: "\x1bP>|Ghostty 1.3.1\x1b\\" },
{ header: "\x1b_Gi=1;", reply: "\x1b_Gi=1;OK\x1b\\" },
{ header: "\x1b]7501;?", reply: "\x1b]7501;?\x1b\\" },
];
for (const { header, reply } of replies) {
it(`keeps delayed ${JSON.stringify(reply)} intact at every split after its reply header`, (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (let split = header.length; split < reply.length; split++) {
const delayedBuffer = new StdinBuffer();
const sequences: string[] = [];
delayedBuffer.on("data", (sequence) => sequences.push(sequence));
try {
delayedBuffer.process(reply.slice(0, split));
t.mock.timers.tick(5000);
delayedBuffer.process(`${reply.slice(split)}x`);
assert.equal(
sequences.some((sequence) => matchesKey(sequence, "ctrl+g")),
false,
);
assert.deepStrictEqual(sequences, [reply, "x"]);
} finally {
delayedBuffer.destroy();
}
}
});
}
it("keeps an OSC reply buffered across multiple expired sequence timeouts", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
processInput("\x1b]4;1;rgb:eaea/6c");
t.mock.timers.tick(100);
assert.deepStrictEqual(emittedSequences, []);
processInput("6c/6d6d");
t.mock.timers.tick(100);
assert.deepStrictEqual(emittedSequences, []);
processInput("\x07");
assert.deepStrictEqual(emittedSequences, [replies[0].reply]);
});
it("still times out bare string introducers as Alt keys", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
for (const prefix of ["\x1b]", "\x1bP", "\x1b_"]) {
processInput(prefix);
t.mock.timers.tick(10);
}
assert.deepStrictEqual(emittedSequences, ["\x1b]", "\x1bP", "\x1b_"]);
});
for (const ambiguous of [
"\x1b]a",
"\x1b]1",
"\x1b]10",
"\x1b]0;title",
"\x1b]4;",
"\x1b]4;a;",
"\x1bPa",
"\x1bP>",
"\x1b_a",
"\x1b_G",
"\x1b_Gi=1",
]) {
it(`recovers batched legacy Alt input ${JSON.stringify(ambiguous)} within the sequence timeout`, (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const legacyBuffer = new StdinBuffer();
const sequences: string[] = [];
legacyBuffer.on("data", (sequence) => sequences.push(sequence));
try {
legacyBuffer.process(ambiguous);
t.mock.timers.tick(20);
legacyBuffer.process("x\r\x03\x04");
t.mock.timers.tick(49);
assert.deepStrictEqual(sequences, []);
t.mock.timers.tick(1);
assert.deepStrictEqual(sequences, [
ambiguous.slice(0, 2),
...ambiguous.slice(2),
"x",
"\r",
"\x03",
"\x04",
]);
assert.strictEqual(legacyBuffer.getBuffer(), "");
legacyBuffer.process("y\r\x03\x04");
assert.deepStrictEqual(sequences.slice(-4), ["y", "\r", "\x03", "\x04"]);
} finally {
legacyBuffer.destroy();
}
});
}
it("recognizes a reply header assembled within the ambiguous-input timeout", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
processInput("\x1b]1");
t.mock.timers.tick(5);
processInput("0;");
t.mock.timers.tick(5000);
assert.deepStrictEqual(emittedSequences, []);
processInput("rgb:ffff/ffff/ffff\x1b\\");
assert.deepStrictEqual(emittedSequences, [replies[1].reply]);
});
it("recovers from an unterminated reply when a new escape sequence arrives", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
processInput("\x1b]4;1;rgb:eaea/6c");
t.mock.timers.tick(100);
processInput("\x1b");
t.mock.timers.tick(100);
processInput("[Ahello");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "h", "e", "l", "l", "o"]);
});
it("discards a canceled reply without swallowing subsequent input", () => {
for (const cancel of ["\x18", "\x1a"]) {
processInput(`\x1b]11;rgb:0000${cancel}x`);
}
assert.deepStrictEqual(emittedSequences, ["x", "x"]);
});
});
describe("Mixed Content", () => {
it("should handle characters followed by escape sequence", () => {
processInput("abc\x1b[A");

View File

@@ -2,12 +2,14 @@ import assert from "node:assert";
import { describe, it } from "node:test";
import {
type Component,
parseOsc11BackgroundColor,
parseTerminalColorSchemeReport,
type Terminal,
type TerminalColors,
type TUI,
TuiMainScreen,
} from "../src/index.ts";
import { StdinBuffer } from "../src/stdin-buffer.ts";
import { parseOscColorResponse } from "../src/terminal-colors.ts";
class TestTerminal implements Terminal {
private inputHandler?: (data: string) => void;
@@ -64,6 +66,7 @@ class TestTerminal implements Terminal {
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
sendInput(data: string): void {
this.inputHandler?.(data);
@@ -90,27 +93,6 @@ class InputRecorder implements Component {
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
describe("parseOsc11BackgroundColor", () => {
it("parses 16-bit OSC 11 rgb responses", () => {
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;rgb:0000/8000/ffff\x07"), {
r: 0,
g: 128,
b: 255,
});
});
it("parses OSC 11 hex responses", () => {
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x1b\\"), { r: 255, g: 255, b: 255 });
assert.deepStrictEqual(parseOsc11BackgroundColor("\x1b]11;#000000\x07"), { r: 0, g: 0, b: 0 });
});
it("rejects non-strict OSC 11 responses", () => {
assert.strictEqual(parseOsc11BackgroundColor(`x\x1b]11;#ffffff\x07`), undefined);
assert.strictEqual(parseOsc11BackgroundColor("\x1b]10;#ffffff\x07"), undefined);
assert.strictEqual(parseOsc11BackgroundColor("\x1b]11;#ffffff\x07x"), undefined);
});
});
describe("parseTerminalColorSchemeReport", () => {
it("parses color scheme reports", () => {
assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark");
@@ -123,128 +105,118 @@ describe("parseTerminalColorSchemeReport", () => {
});
});
describe("TUI.queryTerminalBackgroundColor", () => {
it("writes OSC 11 query and resolves with the parsed RGB reply", async () => {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
tui.start();
try {
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
assert.ok(terminal.writes.includes("\x1b]11;?\x07"));
terminal.sendInput("\x1b]11;#ffffff\x07");
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
} finally {
tui.stop();
}
});
it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputRecorder();
const listenerInputs: string[] = [];
tui.addChild(component);
tui.setFocus(component);
tui.addInputListener((data) => {
listenerInputs.push(data);
return undefined;
describe("parseOscColorResponse", () => {
it("parses OSC 10, 11, and 4 replies", () => {
assert.deepStrictEqual(parseOscColorResponse("\x1b]10;rgb:ffff/ffff/ffff\x07"), {
target: "foreground",
rgb: { r: 255, g: 255, b: 255 },
});
tui.start();
try {
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
terminal.sendInput("\x1b]11;#000000\x07");
assert.deepStrictEqual(await query, { r: 0, g: 0, b: 0 });
assert.deepStrictEqual(listenerInputs, []);
assert.deepStrictEqual(component.inputs, []);
} finally {
tui.stop();
}
});
it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputRecorder();
const listenerInputs: string[] = [];
tui.addChild(component);
tui.setFocus(component);
tui.addInputListener((data) => {
listenerInputs.push(data);
return undefined;
assert.deepStrictEqual(parseOscColorResponse("\x1b]4;13;#ff0080\x1b\\"), {
target: 13,
rgb: { r: 255, g: 0, b: 128 },
});
tui.start();
try {
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 });
terminal.sendInput("\x1b]11;not-a-color\x07");
assert.strictEqual(await query, undefined);
assert.deepStrictEqual(listenerInputs, []);
assert.deepStrictEqual(component.inputs, []);
} finally {
tui.stop();
}
assert.deepStrictEqual(parseOscColorResponse("\x1b]4;1;bogus\x07"), { target: 1, rgb: undefined });
assert.strictEqual(parseOscColorResponse("\x1b]12;#ffffff\x07"), undefined);
});
});
it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputRecorder();
const listenerInputs: string[] = [];
tui.addChild(component);
tui.setFocus(component);
tui.addInputListener((data) => {
listenerInputs.push(data);
return undefined;
});
tui.start();
const PALETTE_REPLIES = Array.from({ length: 16 }, (_, index) => `\x1b]4;${index};#000000\x07`);
const DA1 = "\x1b[?62;22c";
const BLACK = { r: 0, g: 0, b: 0 };
const WHITE = { r: 255, g: 255, b: 255 };
function setup(): { terminal: TestTerminal; tui: TUI; component: InputRecorder } {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputRecorder();
tui.addChild(component);
tui.setFocus(component);
tui.start();
return { terminal, tui, component };
}
describe("TUI.queryTerminalColors", () => {
it("queries all colors in one write and consumes the replies", async () => {
const { terminal, tui, component } = setup();
try {
let settled = false;
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }).then((rgb) => {
settled = true;
return rgb;
});
const query = tui.queryTerminalColors({ timeoutMs: 1000 });
const written = terminal.writes.at(-1) ?? "";
assert.ok(written.startsWith("\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07") && written.endsWith("\x1b[c"));
terminal.sendInput("x");
await Promise.resolve();
assert.strictEqual(settled, false);
assert.deepStrictEqual(listenerInputs, ["x"]);
terminal.sendInput("\x1b]10;#ffffff\x07");
terminal.sendInput("\x1b]11;rgb:0000/0000/0000\x1b\\");
for (const reply of PALETTE_REPLIES) terminal.sendInput(reply);
// Resolves once every reply arrived, without waiting for DA1.
assert.deepStrictEqual(await query, {
foreground: WHITE,
background: BLACK,
palette: Array.from({ length: 16 }, () => BLACK),
});
terminal.sendInput(DA1);
assert.deepStrictEqual(component.inputs, ["x"]);
terminal.sendInput("\x1b]11;#ffffff\x07");
assert.deepStrictEqual(await query, { r: 255, g: 255, b: 255 });
} finally {
tui.stop();
}
});
it("keeps consuming a late OSC 11 reply after timeout", async () => {
const terminal = new TestTerminal();
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputRecorder();
const listenerInputs: string[] = [];
tui.addChild(component);
tui.setFocus(component);
tui.addInputListener((data) => {
listenerInputs.push(data);
return undefined;
});
tui.start();
it("consumes delayed Ghostty color fragments without typing their tail or opening the editor", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { terminal, tui, component } = setup();
const buffer = new StdinBuffer();
buffer.on("data", (data) => terminal.sendInput(data));
try {
const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1 });
await wait(5);
const query = tui.queryTerminalColors({ timeoutMs: 1000 });
// Captured from Ghostty 1.3.1 with Ayu Light, split at the reported tail.
buffer.process("\x1b]4;1;rgb:eaea/6c");
t.mock.timers.tick(100);
buffer.process(`6c/6d6d\x07${DA1}`);
assert.deepStrictEqual(await query, { foreground: undefined, background: undefined, palette: undefined });
assert.deepStrictEqual(component.inputs, []);
assert.strictEqual(await query, undefined);
// A real Ctrl+G must still reach the editor after the reply.
buffer.process("\x07");
assert.deepStrictEqual(component.inputs, ["\x07"]);
} finally {
buffer.destroy();
tui.stop();
}
});
it("resolves on DA1 with the replies that arrived, in query order", async () => {
const { terminal, tui } = setup();
try {
const first = tui.queryTerminalColors({ timeoutMs: 1000 });
const second = tui.queryTerminalColors({ timeoutMs: 1000 });
terminal.sendInput("\x1b]11;#000000\x07");
// An incomplete palette is dropped.
for (const reply of PALETTE_REPLIES.slice(0, 8)) terminal.sendInput(reply);
terminal.sendInput(DA1);
terminal.sendInput(DA1);
assert.deepStrictEqual(await first, { foreground: undefined, background: BLACK, palette: undefined });
assert.deepStrictEqual(await second, { foreground: undefined, background: undefined, palette: undefined });
} finally {
tui.stop();
}
});
it("reports late replies after a timeout and consumes them until DA1", async () => {
const { terminal, tui, component } = setup();
try {
const late: TerminalColors[] = [];
const query = tui.queryTerminalColors({ timeoutMs: 1, onLateReply: (colors) => late.push(colors) });
await wait(5);
assert.strictEqual((await query).background, undefined);
terminal.sendInput("\x1b]11;#ffffff\x07");
assert.deepStrictEqual(listenerInputs, []);
terminal.sendInput(DA1);
assert.deepStrictEqual(late, [{ foreground: undefined, background: WHITE, palette: undefined }]);
assert.deepStrictEqual(component.inputs, []);
// With no query pending, color replies are ordinary input again.
terminal.sendInput("\x1b]11;#ffffff\x07");
assert.deepStrictEqual(component.inputs, ["\x1b]11;#ffffff\x07"]);
} finally {
tui.stop();
}

View File

@@ -5,8 +5,8 @@
import assert from "node:assert";
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { Image } from "../src/components/image.ts";
import { afterEach, beforeEach, describe, it } from "node:test";
import { Image, setImageTranscoder } from "../src/components/image.ts";
import {
cropKittyImageLine,
deleteAllKittyImages,
@@ -18,6 +18,7 @@ import {
getCapabilities,
getKittyImageMetadata,
getKittyImagePlacement,
getKittyImagePlacementRows,
hyperlink,
imageFallback,
isImageLine,
@@ -316,6 +317,19 @@ describe("detectCapabilities", () => {
});
});
// #10573
it("enables hyperlinks and Kitty images for Herdr", () => {
withEnv({ TERM_PROGRAM: "herdr", TERM: "xterm-256color", COLORTERM: "truecolor", KITTY_WINDOW_ID: "1" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
assert.strictEqual(caps.images, "kitty");
assert.strictEqual(caps.trueColor, true);
});
withEnv({ TERM_PROGRAM: "herdr", PI_HYPERLINKS: "0" }, () => {
assert.strictEqual(detectCapabilities().hyperlinks, false);
});
});
it("enables hyperlinks for Ghostty", () => {
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities();
@@ -442,6 +456,12 @@ describe("detectCapabilities", () => {
assert.strictEqual(caps.images, null);
});
});
it("detects truecolor from direct-color TERM values", () => {
withEnv({ TERM: "xterm-direct" }, () => {
assert.strictEqual(detectCapabilities(() => false).trueColor, true);
});
});
});
describe("iTerm2 image encoding", () => {
@@ -457,6 +477,11 @@ describe("Kitty image cursor movement", () => {
assert.ok(sequence.startsWith("\x1b_Ga=T,f=100,q=2,C=1,c=2,r=2;"));
});
it("reads explicit placement rows without registered metadata", () => {
const sequence = encodeKitty("AAAA", { columns: 2, rows: 3, moveCursor: false });
assert.strictEqual(getKittyImagePlacementRows(sequence), 3);
});
it("suppresses Kitty replies for delete commands", () => {
assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\");
assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\");
@@ -526,8 +551,10 @@ describe("Kitty image cursor movement", () => {
const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`;
const placement = getKittyImagePlacement(line);
assert.ok(placement);
assert.strictEqual(getKittyImagePlacementRows(line), 1);
assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length);
assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4);
assert.strictEqual(placement.rows, 1);
assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\");
assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`);
assert.ok(!placement.replacementLine.includes("AAAA"));
@@ -622,6 +649,144 @@ describe("Kitty image cursor movement", () => {
});
});
// #8938: reduce Kitty placement distortion without shrinking iTerm2 reservations.
describe("image cell sizing", () => {
afterEach(() => {
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
});
describe("Kitty", () => {
beforeEach(() => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
});
it("reserves at least one Kitty row for thin images", () => {
setCellDimensions({ widthPx: 9, heightPx: 18 });
const result = renderImage("AAAA", { widthPx: 1200, heightPx: 12 }, { maxWidthCells: 60 });
assert.ok(result);
assert.strictEqual(result.rows, 1);
assert.ok(result.sequence.includes(",c=60,r=1;"));
});
it("keeps Kitty placement, reserved lines, and cropping metadata consistent across width changes", () => {
setCellDimensions({ widthPx: 9, heightPx: 18 });
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 60, imageId: 8938 },
{ widthPx: 615, heightPx: 86 },
);
const lines = image.render(62);
assert.strictEqual(lines.length, 4);
assert.deepStrictEqual(lines.slice(1), ["", "", ""]);
assert.ok(lines[0].includes(",c=60,r=4,i=8938;"));
assert.deepStrictEqual(getKittyImageMetadata(lines[0]), {
imageId: 8938,
columns: 60,
rows: 4,
widthPx: 615,
heightPx: 86,
});
const cropped = cropKittyImageLine(lines[0], 1, 2);
assert.strictEqual(
getKittyImagePlacement(cropped)?.sequence,
"\x1b_Ga=p,q=2,C=1,c=60,i=8938,y=21,h=44,r=2\x1b\\",
);
const narrowerLines = image.render(32);
assert.strictEqual(narrowerLines.length, 2);
assert.ok(narrowerLines[0].includes(",c=30,r=2,i=8938;"));
assert.strictEqual(getKittyImageMetadata(narrowerLines[0])?.rows, 2);
});
it("keeps the ceiling placement when rounding down would increase distortion", () => {
setCellDimensions({ widthPx: 15, heightPx: 28 });
const result = renderImage("AAAA", { widthPx: 615, heightPx: 86 }, { maxWidthCells: 60 });
assert.ok(result);
assert.strictEqual(result.rows, 5);
assert.ok(result.sequence.includes(",c=60,r=5;"));
});
it("keeps height-limited Kitty columns, reservations, and crop metadata consistent", () => {
setCellDimensions({ widthPx: 14, heightPx: 28 });
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 30, imageId: 8938 },
{ widthPx: 400, heightPx: 900 },
);
const lines = image.render(32);
assert.strictEqual(lines.length, 15);
assert.ok(lines[0].includes(",c=13,r=15,i=8938;"));
assert.deepStrictEqual(getKittyImageMetadata(lines[0]), {
imageId: 8938,
columns: 13,
rows: 15,
widthPx: 400,
heightPx: 900,
});
assert.strictEqual(
getKittyImagePlacement(cropKittyImageLine(lines[0], 1, 2))?.sequence,
"\x1b_Ga=p,q=2,C=1,c=13,i=8938,y=60,h=120,r=2\x1b\\",
);
const narrowerLines = image.render(22);
assert.strictEqual(narrowerLines.length, 10);
assert.ok(narrowerLines[0].includes(",c=9,r=10,i=8938;"));
});
it("chooses thin Kitty widths by proportions while keeping at least one column", () => {
setCellDimensions({ widthPx: 1, heightPx: 1 });
for (const [widthPx, columns] of [
[1, 1],
[140, 1],
[149, 2],
]) {
const result = renderImage("AAAA", { widthPx, heightPx: 1000 }, { maxWidthCells: 30, maxHeightCells: 10 });
assert.ok(result);
assert.strictEqual(result.columns, columns);
assert.strictEqual(result.rows, 10);
assert.ok(result.sequence.includes(`,c=${columns},r=10;`));
}
});
});
describe("iTerm2", () => {
beforeEach(() => {
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
});
it("keeps iTerm2's ceiling width when height-limited", () => {
setCellDimensions({ widthPx: 14, heightPx: 28 });
const result = renderImage("AAAA", { widthPx: 400, heightPx: 900 }, { maxWidthCells: 30, maxHeightCells: 15 });
assert.ok(result);
assert.strictEqual(result.columns, 14);
assert.strictEqual(result.rows, 15);
assert.strictEqual(result.sequence, "\x1b]1337;File=inline=1;size=3;width=14;height=auto:AAAA\x07");
});
it("keeps iTerm2's ceiling-based reserved lines and cursor offset", () => {
setCellDimensions({ widthPx: 9, heightPx: 18 });
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 60 },
{ widthPx: 615, heightPx: 86 },
);
assert.deepStrictEqual(image.render(62), [
"",
"",
"",
"",
"\x1b[4A\x1b]1337;File=inline=1;size=3;width=60;height=auto:AAAA\x07",
]);
});
});
});
describe("imageFallback", () => {
it("shortens home-prefixed absolute paths without hyperlinks", () => {
setCapabilities({ images: null, trueColor: false, hyperlinks: false });
@@ -673,6 +838,70 @@ describe("imageFallback", () => {
});
});
// Kitty only accepts PNG (f=100); non-PNG images must be transcoded (#10292)
describe("Image transcoding", () => {
const jpeg = Buffer.from("jpeg").toString("base64");
// Minimal PNG header (signature + IHDR) for a 40x10 image. Enough for getPngDimensions.
const png = Buffer.from("89504e470d0a1a0a0000000d49484452000000280000000a", "hex").toString("base64");
let calls: string[];
const render = (data: string, mimeType: string) =>
new Image(data, mimeType, { fallbackColor: (value) => value }, {}, { widthPx: 20, heightPx: 20 }).render(20);
const transcode = (data: string) => {
calls.push(data);
return data === jpeg ? png : null;
};
beforeEach(() => {
calls = [];
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
setCellDimensions({ widthPx: 10, heightPx: 10 });
});
afterEach(() => {
setImageTranscoder(undefined);
resetCapabilitiesCache();
setCellDimensions({ widthPx: 9, heightPx: 18 });
});
it("sends converted PNG data sized from the PNG", () => {
setImageTranscoder(transcode);
const lines = render(jpeg, "image/jpeg");
assert.ok(lines[0].includes("f=100") && lines[0].includes(`;${png}\x1b\\`));
// 40x10 PNG at 18 columns: 5 rows, not the 18 rows of the 20x20 source dimensions.
assert.strictEqual(lines.length, 5);
});
it("renders a text fallback until a working transcoder is registered", () => {
const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value });
assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/);
setImageTranscoder(() => null);
image.invalidate();
assert.match(image.render(80)[0], /^\[Image: \[image\/jpeg\]/);
setImageTranscoder(transcode);
image.invalidate();
assert.ok(image.render(80)[0].includes("\x1b_G"));
});
it("converts each image once", () => {
setImageTranscoder(transcode);
const image = new Image(jpeg, "image/jpeg", { fallbackColor: (value) => value });
image.render(80);
render(jpeg, "image/jpeg"); // New instance hits the shared cache.
for (let i = 0; i < 40; i++) render(`other-${i}`, "image/jpeg"); // Evicts the shared entry.
image.invalidate();
image.render(40); // Instance keeps its own PNG.
assert.strictEqual(calls.filter((data) => data === jpeg).length, 1);
});
it("does not convert PNG data or iTerm2 output", () => {
setImageTranscoder(transcode);
assert.ok(render(png, "image/png")[0].includes(`;${png}\x1b\\`));
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
assert.ok(render(jpeg, "image/jpeg").at(-1)?.endsWith(`:${jpeg}\x07`));
assert.deepStrictEqual(calls, []);
});
});
describe("hyperlink", () => {
it("wraps text in OSC 8 open and close sequences", () => {
const result = hyperlink("click me", "https://example.com");

View File

@@ -132,7 +132,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
it("queries Kitty mode before enabling modifyOtherKeys fallback", () => {
const harness = setupNegotiation();
try {
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b]7501;?\x1b\\\x1b[c");
assert.equal(harness.writes.includes("\x1b[>4;2m"), false);
assert.equal(harness.terminal.kittyProtocolActive, false);
} finally {
@@ -187,6 +187,21 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
}
});
it("forwards device attributes replies that answer other queries", () => {
const harness = setupNegotiation();
try {
harness.send("\x1b[?7u");
harness.send("\x1b[?62;4;52c");
assert.equal(harness.getInput(), undefined);
// The TUI's color query uses DA1 as its own sentinel.
harness.send("\x1b[?62;4;52c");
assert.equal(harness.getInput(), "\x1b[?62;4;52c");
} finally {
harness.cleanup();
}
});
it("forwards normal input while waiting for Kitty response", () => {
const harness = setupNegotiation();
try {
@@ -218,6 +233,129 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
}
});
// #10607
describe("program status (OSC 7501)", () => {
const working = "\x1b]7501;state=working:app=pi\x1b\\";
const clear = "\x1b]7501;state=clear\x1b\\";
function withOverride(value: string | undefined, fn: () => void): void {
const previous = process.env.PI_PROGRAM_STATUS;
if (value === undefined) delete process.env.PI_PROGRAM_STATUS;
else process.env.PI_PROGRAM_STATUS = value;
try {
fn();
} finally {
if (previous === undefined) delete process.env.PI_PROGRAM_STATUS;
else process.env.PI_PROGRAM_STATUS = previous;
}
}
it("reports the latest status once the terminal answers the query before DA", () => {
withOverride(undefined, () => {
const harness = setupNegotiation();
try {
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
assert.equal(harness.writes.includes(working), false);
harness.send("\x1b]7501;?\x1b\\");
assert.equal(harness.getInput(), undefined);
assert.equal(harness.writes.filter((write) => write === working).length, 1);
harness.send("\x1b[?62;4;52c");
harness.terminal.setProgramStatus({ state: "done" });
assert.equal(harness.writes.at(-1), "\x1b]7501;state=done\x1b\\");
} finally {
harness.cleanup();
}
});
});
it("reports nothing when DA arrives first and swallows late replies", () => {
withOverride(undefined, () => {
const harness = setupNegotiation();
try {
harness.send("\x1b[?62;4;52c");
harness.send("\x1b]7501;?\x07");
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
assert.equal(harness.getInput(), undefined);
assert.equal(
harness.writes.some((write) => write.startsWith("\x1b]7501;state=")),
false,
);
} finally {
harness.cleanup();
}
});
});
it("skips the query when PI_PROGRAM_STATUS is set", () => {
withOverride("1", () => {
const harness = setupNegotiation();
try {
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
assert.equal(harness.writes.at(-1), working);
} finally {
harness.cleanup();
}
});
withOverride("0", () => {
const harness = setupNegotiation();
try {
assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c");
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
assert.equal(harness.writes.includes(working), false);
} finally {
harness.cleanup();
}
});
});
it("does not let a DA reply from before a restart end the new query", () => {
withOverride(undefined, () => {
const harness = setupNegotiation();
try {
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
harness.terminal.stop();
(harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol();
// The first start's replies arrive late: its DA, then the second start's reply and DA.
harness.send("\x1b[?62;4;52c");
harness.send("\x1b]7501;?\x1b\\");
harness.send("\x1b[?62;4;52c");
assert.equal(harness.writes.at(-1), working);
assert.equal(harness.getInput(), undefined);
} finally {
harness.cleanup();
}
});
});
it("clears the status on stop and reports it again after restart", () => {
withOverride(undefined, () => {
const harness = setupNegotiation();
try {
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
harness.send("\x1b]7501;?\x1b\\");
harness.terminal.stop();
assert.ok(harness.writes.includes(clear));
// Stopped: nothing is written until the restarted terminal confirms support again.
const writesBeforeRestart = harness.writes.length;
harness.terminal.setProgramStatus({ state: "working", app: "pi" });
assert.equal(harness.writes.length, writesBeforeRestart);
(harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol();
harness.send("\x1b]7501;?\x1b\\");
assert.equal(harness.writes.at(-1), working);
} finally {
harness.cleanup();
}
});
});
});
it("replays buffered CSI-prefix input when it is not a Kitty response", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const harness = setupNegotiation();

View File

@@ -20,7 +20,7 @@ import {
resetCapabilitiesCache,
setCapabilities,
} from "../src/terminal-image.ts";
import type { TuiMouseEvent } from "../src/tui.ts";
import { renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts";
import { TuiAltScreen } from "../src/tui-alt-screen.ts";
import { stripTerminalSequences, visibleWidth } from "../src/utils.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
@@ -138,7 +138,51 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => {
it("keeps the jump-to-end indicator centered as the auto scrollbar hides and reappears", async () => {
// Regression test for #9136: auto scrollbar visibility must not move the indicator.
const terminal = new VirtualTerminal(80, 6);
const label = " ↓ Jump to latest message · End ";
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => label,
});
const transcript = new ScrollView(
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true, scrollbar: "auto", scrollbarHideDelayMs: 0 },
);
tui.setLayoutRoot(transcript);
tui.start();
try {
await terminal.waitForRender();
// Scrolling over the track keeps the scrollbar visible until the pointer leaves.
terminal.sendInput("\x1b[<64;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.isFollowingEnd, false);
const scrollTop = transcript.scrollTop;
const visibleColumn = terminal.getViewport()[5].indexOf(label);
// Leaving the track lets the auto-hide timer expire without changing the content.
terminal.sendInput("\x1b[<35;79;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, false);
assert.strictEqual(transcript.scrollTop, scrollTop);
const hiddenColumn = terminal.getViewport()[5].indexOf(label);
terminal.sendInput("\x1b[<35;80;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isScrollbarVisible, true);
assert.strictEqual(transcript.scrollTop, scrollTop);
const revealedColumn = terminal.getViewport()[5].indexOf(label);
assert.deepStrictEqual([visibleColumn, hiddenColumn, revealedColumn], [24, 24, 24]);
} finally {
tui.stop();
}
});
it("leaves the scrollbar visible and clickable when the jump-to-end indicator spans the transcript", async () => {
// Regression coverage for #9136: centering must not paint or capture clicks over the scrollbar.
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => "↓".repeat(30),
@@ -161,6 +205,7 @@ describe("TuiAltScreen", () => {
assert.strictEqual(transcript.isFollowingEnd, false);
// The indicator must not intercept a press on the scrollbar's last column.
assert.strictEqual(terminal.getViewport()[3], `${"↓".repeat(29)}┃`);
terminal.sendInput("\x1b[<0;30;4M");
terminal.sendInput("\x1b[<0;30;4m");
await terminal.waitForRender();
@@ -459,6 +504,31 @@ describe("TuiAltScreen", () => {
tui.stop();
});
// #9758: wheel line counts can change at runtime; Alt keeps its multiplier.
it("applies runtime wheel line count updates", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 });
const deltas: Array<number | undefined> = [];
tui.addChild(
new MouseRegion(new Text("wheel target", 0, 0), (event) => {
if (event.type !== "wheel") return undefined;
deltas.push(event.wheelDelta);
return { handled: true };
}),
);
tui.start();
try {
await terminal.waitForRender();
terminal.sendInput("\x1b[<64;1;1M");
tui.setWheelScrollLines(2);
terminal.sendInput("\x1b[<65;1;1M");
terminal.sendInput("\x1b[<72;1;1M");
assert.deepStrictEqual(deltas, [-3, 2, -10]);
} finally {
tui.stop();
}
});
it("chains unused wheel delta to an outer scroll view", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 });
@@ -506,14 +576,14 @@ describe("TuiAltScreen", () => {
["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"],
);
terminal.sendInput("\x1bOH");
terminal.sendInput("\x1b[7^");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8"],
);
terminal.sendInput("\x1bOF");
terminal.sendInput("\x1b[8^");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
@@ -828,7 +898,7 @@ describe("TuiAltScreen", () => {
}
});
it("routes Ctrl-modified viewport navigation to the focused component", async () => {
it("routes Home and End to the focused component and Ctrl+Home/End to the transcript", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const transcript = new ScrollView(
@@ -852,22 +922,34 @@ describe("TuiAltScreen", () => {
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1bOH");
const bottom = transcript.scrollTop;
assert.ok(bottom > 0);
// #10314: unmodified Home/End belong to the editor in every UI mode.
const editorKeys = ["\x1bOH", "\x1b[F", "\x1b[57423u", "\x1b[5;5~", "\x1b[6;5~"];
for (const input of editorKeys) terminal.sendInput(input);
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, bottom);
assert.deepStrictEqual(editorInputs, editorKeys);
terminal.sendInput("\x1b[1;5H");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 0);
assert.deepStrictEqual(editorInputs, []);
const modifiedInputs = ["\x1b[1;5H", "\x1b[1;5F", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"];
for (const input of modifiedInputs) terminal.sendInput(input);
terminal.sendInput("\x1b[1;5F");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, bottom);
assert.strictEqual(transcript.isFollowingEnd, true);
terminal.sendInput("\x1b[57423;5u");
terminal.sendInput("\x1b[57423;5:3u");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 0);
assert.deepStrictEqual(editorInputs, modifiedInputs);
terminal.sendInput("\x1b[6~");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 1);
assert.deepStrictEqual(editorInputs, modifiedInputs);
assert.deepStrictEqual(editorInputs, editorKeys);
tui.stop();
});
@@ -992,6 +1074,47 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("redraws WezTerm Kitty images after writes to covered rows", async () => {
// Regression test for #10319: a scrollbar update below an unchanged image anchor erased its cells.
const weztermPane = process.env.WEZTERM_PANE;
let tui: TuiAltScreen | undefined;
process.env.WEZTERM_PANE = "1";
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 4);
const imageId = 10319;
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
let coveredLine = "";
tui = new TuiAltScreen(terminal);
tui.setLayoutRoot({
render: () => [imageLine, coveredLine, "", "after"],
invalidate: () => {},
});
tui.start();
await terminal.waitForRender();
const eventCount = terminal.events.length;
coveredLine = "changed";
tui.requestRender();
await terminal.waitForRender();
const redrawWrites = terminal.events
.slice(eventCount)
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
const placementIndex = redrawWrites.indexOf("\x1b_Ga=p,q=2");
assert.ok(redrawWrites.includes("\x1b_Ga=d,d=a,q=2\x1b\\"));
assert.ok(placementIndex > redrawWrites.indexOf("changed"));
assert.ok(!redrawWrites.includes("\x1b_Ga=T"));
} finally {
tui?.stop();
resetCapabilitiesCache();
if (weztermPane === undefined) delete process.env.WEZTERM_PANE;
else process.env.WEZTERM_PANE = weztermPane;
}
});
it("reuses moved Kitty images without dropping HStack siblings", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
try {
@@ -1239,6 +1362,26 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("keeps the selection highlight after a fake cursor cell", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(`> ${renderFakeCursor("a")}bcd`, 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;6;1M");
terminal.sendInput("\x1b[<3;6;1m");
await terminal.waitForRender();
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[27m\x1b[7mbcd")),
"selection inverse must be reapplied after the fake cursor ends",
);
tui.stop();
});
it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
@@ -1351,6 +1494,36 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("flashes a specific error returned by the injected copySelection handler", async () => {
// Regression test for #9618.
const terminal = new RecordingTerminal(80, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copyOnSelect: false,
copySelection: async () => "Clipboard unavailable: install wl-clipboard",
});
let flashDuration: number | undefined;
const flash = tui.flash.bind(tui);
tui.flash = (message, durationMs) => {
flashDuration = durationMs;
flash(message, durationMs);
};
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false);
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("Clipboard unavailable: install wl-clipboard")));
assert.ok(terminal.getViewport().every((line) => !line.includes("Copy failed")));
assert.strictEqual(flashDuration, 5000);
tui.stop();
});
it("does not append whitespace to double-click word highlighting", async () => {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
@@ -1689,6 +1862,26 @@ describe("TuiAltScreen", () => {
tui.stop();
});
// #10769
it("scrolls the transcript on wheel input a focused overlay does not handle", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
const overlay = new InputOverlay();
tui.start();
await terminal.waitForRender();
const topBefore = tui.viewportTop;
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
await terminal.waitForRender();
assert.strictEqual(overlay.focused, true);
terminal.sendInput("\x1b[<64;3;1M");
await terminal.waitForRender();
assert.ok(tui.viewportTop < topBefore);
assert.deepStrictEqual(overlay.inputs, []);
tui.stop();
});
it("focuses and captures drag gestures for mouse-aware components", async () => {
const terminal = new VirtualTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
@@ -1828,7 +2021,7 @@ describe("TuiAltScreen", () => {
}
});
it("gives wheel and viewport keys to a focused overlay", async () => {
it("gives viewport keys to a focused overlay", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
@@ -1840,8 +2033,7 @@ describe("TuiAltScreen", () => {
await terminal.waitForRender();
assert.strictEqual(overlay.focused, true);
const wheel = "\x1b[<64;10;3M";
const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF", wheel];
const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF"];
for (const key of keys) terminal.sendInput(key);
await terminal.waitForRender();

View File

@@ -5,6 +5,7 @@ import { join } from "node:path";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Image } from "../src/components/image.ts";
import { Input } from "../src/components/input.ts";
import type { Terminal } from "../src/terminal.ts";
import {
deleteKittyImage,
@@ -13,8 +14,9 @@ import {
setCapabilities,
setCellDimensions,
} from "../src/terminal-image.ts";
import type { Component, TUI } from "../src/tui.ts";
import { type Component, CURSOR_MARKER, renderFakeCursor, type TUI } from "../src/tui.ts";
import { TuiMainScreen } from "../src/tui-main-screen.ts";
import { sliceByColumn } from "../src/utils.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
class TestComponent implements Component {
@@ -60,6 +62,7 @@ class BoundedWriteTerminal implements Terminal {
clearScreen(): void {}
setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
}
class LoggingVirtualTerminal extends VirtualTerminal {
@@ -950,3 +953,72 @@ describe("TUI differential rendering", () => {
tui.stop();
});
});
describe("TUI fake cursor", () => {
it("draws the focused fake cursor in reverse video and omits it when the hardware cursor is shown", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui: TUI = new TuiMainScreen(terminal);
const input = new Input();
input.setValue("abc");
tui.addChild(input);
tui.setFocus(input);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc"));
terminal.clearWrites();
tui.setShowHardwareCursor(true);
await terminal.waitForRender();
assert.ok(!terminal.getWrites().includes("\x1b[7m"));
assert.ok(!terminal.getWrites().includes("\x1b_pi:"));
tui.stop();
});
it("keeps the fake cursor of an unfocused input when the hardware cursor is shown", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui: TUI = new TuiMainScreen(terminal, true);
const input = new Input();
input.setValue("abc");
tui.addChild(input);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc"));
tui.stop();
});
it("omits the focused fake cursor when truncation dropped its end marker", async () => {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui: TUI = new TuiMainScreen(terminal, true);
const base = new TestComponent();
base.lines = [sliceByColumn(`ab${CURSOR_MARKER}${renderFakeCursor("c")}def`, 0, 3, true)];
tui.addChild(base);
tui.start();
await terminal.waitForRender();
assert.ok(!terminal.getWrites().includes("\x1b[7m"));
assert.ok(terminal.getWrites().endsWith("\x1b[3G"));
tui.stop();
});
it("keeps the cursor when an overlay ends directly left of it", async () => {
for (const showHardwareCursor of [false, true]) {
const terminal = new LoggingVirtualTerminal(20, 5);
const tui: TUI = new TuiMainScreen(terminal, showHardwareCursor);
const base = new TestComponent();
base.lines = [`abc${CURSOR_MARKER}${renderFakeCursor("d")}ef`];
tui.addChild(base);
const overlay = new TestComponent();
overlay.lines = ["XXX"];
tui.showOverlay(overlay, { row: 0, col: 0, width: 3 });
tui.start();
await terminal.waitForRender();
const writes = terminal.getWrites();
assert.ok(writes.includes("XXX"));
assert.strictEqual(writes.includes("\x1b[7md"), !showHardwareCursor);
// The hardware cursor is positioned at column 4 in both modes
assert.ok(writes.endsWith("\x1b[4G"));
tui.stop();
}
});
});

View File

@@ -2,11 +2,11 @@
* 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
* Run from repo root: node 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 send-keys -t tui-bug "node packages/tui/test/viewport-overwrite-repro.ts" Enter
* tmux attach -t tui-bug
*
* Expected behavior:

View File

@@ -101,6 +101,7 @@ export class VirtualTerminal implements Terminal {
}
setProgress(_active: boolean): void {}
setProgramStatus(): void {}
// Test-specific methods not in Terminal interface

View File

@@ -0,0 +1,26 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth } from "../src/utils.ts";
describe("visibleWidth", () => {
it("measures styled ASCII without counting escape sequences", () => {
assert.strictEqual(visibleWidth("\x1b[38;5;4mhello\x1b[39m world"), 11);
assert.strictEqual(visibleWidth("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), 4);
assert.strictEqual(visibleWidth("\x1b]133;A\x1b\\prompt"), 6);
assert.strictEqual(visibleWidth("\x1b_pi:c\x07cursor"), 6);
});
it("counts tabs as three columns in styled text", () => {
assert.strictEqual(visibleWidth("\x1b[1ma\tb\x1b[22m"), 5);
});
it("measures styled non-ASCII text", () => {
assert.strictEqual(visibleWidth("\x1b[31m日本\x1b[39m ok"), 7);
assert.strictEqual(visibleWidth("\x1b[31m─→\x1b[39m"), 2);
});
it("treats unterminated escape sequences as zero-width control characters", () => {
assert.strictEqual(visibleWidth("\x1b[31"), 3);
assert.strictEqual(visibleWidth("a\x1b"), 1);
});
});

View File

@@ -0,0 +1,47 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { WheelScrollAccelerator } from "../src/wheel-scroll.ts";
function scroll(accelerator: WheelScrollAccelerator, times: number[], direction: -1 | 1 = 1): number[] {
return times.map((time) => accelerator.next(direction, time));
}
// #9758: fullscreen wheel scrolling was one line per notch on terminals that do not accelerate wheels.
describe("WheelScrollAccelerator", () => {
it("uses fixed line counts regardless of timing", () => {
const accelerator = new WheelScrollAccelerator(3, true);
assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 1000]), [3, 3, 3, 3]);
accelerator.setLines(0.5);
assert.strictEqual(accelerator.next(1, 2000), 1);
});
it("keeps one line per event in auto mode when the terminal already accelerates", () => {
const accelerator = new WheelScrollAccelerator("auto", false);
assert.deepStrictEqual(scroll(accelerator, [0, 10, 20, 30]), [1, 1, 1, 1]);
});
it("scales auto mode with wheel velocity", () => {
const accelerator = new WheelScrollAccelerator("auto", true);
assert.deepStrictEqual(scroll(accelerator, [0, 150, 300, 450]), [1, 1, 1, 1]);
assert.deepStrictEqual(scroll(accelerator, [1000, 1050, 1100, 1150]), [1, 2, 2, 2]);
assert.deepStrictEqual(scroll(accelerator, [2000, 2020, 2040, 2060]), [1, 5, 5, 5]);
assert.deepStrictEqual(scroll(accelerator, [3000, 3010, 3020, 3030]), [1, 6, 6, 6]);
});
it("does not accelerate bursts of events for a single notch", () => {
const accelerator = new WheelScrollAccelerator("auto", true);
assert.deepStrictEqual(scroll(accelerator, [0, 3, 6, 9]), [1, 1, 1, 1]);
});
it("resets acceleration on direction changes and pauses", () => {
const accelerator = new WheelScrollAccelerator("auto", true);
assert.deepStrictEqual(scroll(accelerator, [0, 20, 40]), [1, 5, 5]);
assert.strictEqual(accelerator.next(-1, 60), 1);
assert.deepStrictEqual(scroll(accelerator, [500, 520]), [1, 5]);
});
it("carries fractional lines between events", () => {
const accelerator = new WheelScrollAccelerator("auto", true);
assert.deepStrictEqual(scroll(accelerator, [0, 40, 80, 120, 160]), [1, 2, 3, 2, 3]);
});
});