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:
@@ -67,6 +67,7 @@ class NullTerminal implements Terminal {
|
||||
clearScreen(): void {}
|
||||
setTitle(_title: string): void {}
|
||||
setProgress(_active: boolean): void {}
|
||||
setProgramStatus(): void {}
|
||||
}
|
||||
|
||||
const userMarkdown = [
|
||||
|
||||
98
third_party/pi/packages/tui/test/autocomplete-skill-slash.test.ts
vendored
Normal file
98
third_party/pi/packages/tui/test/autocomplete-skill-slash.test.ts
vendored
Normal 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"));
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
47
third_party/pi/packages/tui/test/colors.test.ts
vendored
Normal file
47
third_party/pi/packages/tui/test/colors.test.ts
vendored
Normal 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$/);
|
||||
});
|
||||
});
|
||||
227
third_party/pi/packages/tui/test/editor.test.ts
vendored
227
third_party/pi/packages/tui/test/editor.test.ts
vendored
@@ -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);
|
||||
|
||||
|
||||
31
third_party/pi/packages/tui/test/fixtures/clipboard-reader.cjs
vendored
Normal file
31
third_party/pi/packages/tui/test/fixtures/clipboard-reader.cjs
vendored
Normal 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; });
|
||||
64
third_party/pi/packages/tui/test/fixtures/clipboard-worker-test.c
vendored
Normal file
64
third_party/pi/packages/tui/test/fixtures/clipboard-worker-test.c
vendored
Normal 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;
|
||||
}
|
||||
66
third_party/pi/packages/tui/test/fixtures/clipboard-worker-test.cjs
vendored
Normal file
66
third_party/pi/packages/tui/test/fixtures/clipboard-worker-test.cjs
vendored
Normal 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;
|
||||
});
|
||||
177
third_party/pi/packages/tui/test/fixtures/clipboard-x11-test.c
vendored
Normal file
177
third_party/pi/packages/tui/test/fixtures/clipboard-x11-test.c
vendored
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
14
third_party/pi/packages/tui/test/keys.test.ts
vendored
14
third_party/pi/packages/tui/test/keys.test.ts
vendored
@@ -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", () => {
|
||||
|
||||
32
third_party/pi/packages/tui/test/latex.test.ts
vendored
32
third_party/pi/packages/tui/test/latex.test.ts
vendored
@@ -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", () => {
|
||||
|
||||
@@ -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}\)`,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
247
third_party/pi/packages/tui/test/native-clipboard-linux.test.ts
vendored
Normal file
247
third_party/pi/packages/tui/test/native-clipboard-linux.test.ts
vendored
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
100
third_party/pi/packages/tui/test/native-platform.test.ts
vendored
Normal file
100
third_party/pi/packages/tui/test/native-platform.test.ts
vendored
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
51
third_party/pi/packages/tui/test/program-status.test.ts
vendored
Normal file
51
third_party/pi/packages/tui/test/program-status.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
17
third_party/pi/packages/tui/test/regression-slice-by-column-ansi-order.test.ts
vendored
Normal file
17
third_party/pi/packages/tui/test/regression-slice-by-column-ansi-order.test.ts
vendored
Normal 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.");
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
140
third_party/pi/packages/tui/test/terminal.test.ts
vendored
140
third_party/pi/packages/tui/test/terminal.test.ts
vendored
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -101,6 +101,7 @@ export class VirtualTerminal implements Terminal {
|
||||
}
|
||||
|
||||
setProgress(_active: boolean): void {}
|
||||
setProgramStatus(): void {}
|
||||
|
||||
// Test-specific methods not in Terminal interface
|
||||
|
||||
|
||||
26
third_party/pi/packages/tui/test/visible-width.test.ts
vendored
Normal file
26
third_party/pi/packages/tui/test/visible-width.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
47
third_party/pi/packages/tui/test/wheel-scroll.test.ts
vendored
Normal file
47
third_party/pi/packages/tui/test/wheel-scroll.test.ts
vendored
Normal 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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user