Files
dev/third_party/pi/packages/tui/test/tui-alt-screen.test.ts
alexion 6081e3e325 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
2026-10-15 17:58:48 -04:00

2101 lines
74 KiB
TypeScript

import assert from "node:assert";
import { describe, it } from "node:test";
import {
AltScreenSearchComponent,
AltScreenSearchIndex,
findAltScreenSearchMatches,
} from "../src/alt-screen-search.ts";
import { HStack } from "../src/components/h-stack.ts";
import { Image } from "../src/components/image.ts";
import { MouseRegion } from "../src/components/mouse-region.ts";
import { ScrollView } from "../src/components/scroll-view.ts";
import { SelectList } from "../src/components/select-list.ts";
import { Text } from "../src/components/text.ts";
import { VStack } from "../src/components/v-stack.ts";
import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts";
import {
encodeKitty,
hyperlink,
registerKittyImageMetadata,
resetCapabilitiesCache,
setCapabilities,
} from "../src/terminal-image.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";
const OSC133_ZONE_START = "\x1b]133;A\x07";
class InputOverlay {
focused = false;
inputs: string[] = [];
handleInput(data: string): void {
this.inputs.push(data);
}
render(): string[] {
return ["overlay"];
}
invalidate(): void {}
}
class RecordingTerminal extends VirtualTerminal {
readonly events: Array<{ type: "write"; data: string } | { type: "start" } | { type: "stop" }> = [];
override start(onInput: (data: string) => void, onResize: () => void): void {
this.events.push({ type: "start" });
super.start(onInput, onResize);
}
override write(data: string): void {
this.events.push({ type: "write", data });
super.write(data);
}
override stop(): void {
this.events.push({ type: "stop" });
super.stop();
}
}
describe("TuiAltScreen", () => {
it("renders a terminal-height viewport and preserves manual scroll position", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const text = new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0);
tui.addChild(text);
tui.start();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 7", "line 8", "line 9", "line 10"],
);
assert.strictEqual(tui.isFollowingOutput, true);
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 6", "line 7", "line 8", "line 9"],
);
assert.strictEqual(tui.viewportTop, 5);
assert.strictEqual(tui.isFollowingOutput, false);
text.setText(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"));
tui.requestRender();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 6", "line 7", "line 8", "line 9"],
);
tui.stop();
});
it("shows a clickable jump-to-end indicator on the transcript's last row while scrolled up", async () => {
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => "\x1b[7m ↓ Jump to end \x1b[27m",
});
const transcript = new ScrollView(
new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true },
);
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: new Text("editor\nfooter", 0, 0), basis: "auto", minSize: 1 },
]),
);
tui.start();
await terminal.waitForRender();
assert.ok(!terminal.getViewport().some((line) => line.includes("Jump to end")));
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
assert.strictEqual(terminal.getViewport()[3], "line 7 ↓ Jump to end ");
assert.strictEqual(terminal.getViewport()[4]?.trimEnd(), "editor");
// Pressing next to the label starts a selection instead of jumping.
terminal.sendInput("\x1b[<0;2;4M");
terminal.sendInput("\x1b[<0;2;4m");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
terminal.sendInput("\x1b[<0;15;4M");
terminal.sendInput("\x1b[<0;15;4m");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, true);
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8", "editor", "footer"],
);
tui.stop();
});
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),
});
const transcript = new ScrollView(
new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true, scrollbar: "always" },
);
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: new Text("editor\nfooter", 0, 0), basis: "auto", minSize: 1 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
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();
assert.strictEqual(transcript.isFollowingEnd, false);
tui.stop();
});
it("never shows the jump-to-end indicator for a primary scroll view without follow-end", async () => {
const terminal = new VirtualTerminal(30, 3);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => " ↓ Jump to end ",
});
const transcript = new ScrollView(new Text("one\ntwo\nthree\nfour\nfive", 0, 0), { primary: true });
tui.setLayoutRoot(transcript);
tui.start();
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
assert.ok(!terminal.getViewport().some((line) => line.includes("Jump to end")));
tui.stop();
});
it("keeps an explicit dock fixed while the transcript scrolls", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const transcriptText = new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0);
const transcript = new ScrollView(transcriptText, { follow: "end", primary: true });
const dock = new VStack([new Text("editor", 0, 0), new Text("footer", 0, 0)]);
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: dock, basis: "auto", minSize: 1 },
]),
);
tui.start();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8", "editor", "footer"],
);
// Wheel over the dock falls back to the primary transcript scroll view.
terminal.sendInput("\x1b[<64;1;6M");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 4", "line 5", "line 6", "line 7", "editor", "footer"],
);
assert.strictEqual(transcript.isFollowingEnd, false);
transcriptText.setText(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"));
tui.requestRender();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 4", "line 5", "line 6", "line 7", "editor", "footer"],
);
tui.scrollToBottom();
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 7", "line 8", "line 9", "line 10", "editor", "footer"],
);
tui.stop();
});
it("invalidates overlays with an explicit layout root", () => {
const tui = new TuiAltScreen(new VirtualTerminal());
const overlay = new Text("overlay", 0, 0);
let invalidated = false;
overlay.invalidate = () => {
invalidated = true;
};
tui.setLayoutRoot(new Text("root", 0, 0));
tui.showOverlay(overlay);
tui.invalidate();
assert.strictEqual(invalidated, true);
tui.stop();
});
it("routes wheel input to the scroll view under the pointer", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const left = new ScrollView(new Text("a1\na2\na3\na4\na5\na6\na7", 0, 0), {
follow: "end",
primary: true,
});
const right = new ScrollView(new Text("b1\nb2\nb3\nb4\nb5\nb6\nb7", 0, 0), { follow: "end" });
tui.setLayoutRoot(
new HStack([
{ component: left, basis: 10, shrink: 0 },
{ component: right, basis: 10, shrink: 0 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<64;15;1M");
await terminal.waitForRender();
assert.strictEqual(left.scrollTop, 3);
assert.strictEqual(right.scrollTop, 2);
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["a4 b3", "a5 b4", "a6 b5", "a7 b6"],
);
tui.stop();
});
it("scrolls faster while Alt is held during wheel input", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
const text = new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0);
tui.addChild(text);
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 8);
// Alt modifier sets bit 8 on the wheel button (72 = 64 + 8).
terminal.sendInput("\x1b[<72;1;1M");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 3);
tui.stop();
});
it("does not vertically redispatch misses through horizontal layout containers", async () => {
const terminal = new VirtualTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let selections = 0;
const list = new SelectList(
[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
2,
{
selectedPrefix: (text) => text,
selectedText: (text) => text,
description: (text) => text,
scrollInfo: (text) => text,
noMatch: (text) => text,
},
);
list.onSelect = () => {
selections += 1;
};
tui.setLayoutRoot(
new HStack([
{ component: list, basis: 10 },
{ component: new Text("plain", 0, 0), basis: 10 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;15;1M");
terminal.sendInput("\x1b[<0;15;1m");
await terminal.waitForRender();
assert.strictEqual(selections, 0);
tui.stop();
});
it("uses button-motion tracking inside terminal multiplexers", () => {
const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const;
const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
try {
for (const key of environmentKeys) delete process.env[key];
process.env.TERM = "xterm-256color";
const directTerminal = new RecordingTerminal();
const directTui = new TuiAltScreen(directTerminal);
directTui.start();
const directWrites = directTerminal.events
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(directWrites.includes("\x1b[?1003h"));
directTui.stop();
const multiplexers = [
{ name: "tmux environment", environment: { TMUX: "/tmp/tmux/default,1,0" } },
{ name: "tmux TERM", environment: { TERM: "tmux-256color" } },
{ name: "Zellij environment", environment: { ZELLIJ: "0" } },
{ name: "Screen environment", environment: { STY: "123.session" } },
{ name: "Screen TERM", environment: { TERM: "screen-256color" } },
];
for (const { name, environment } of multiplexers) {
for (const key of environmentKeys) delete process.env[key];
for (const [key, value] of Object.entries(environment)) process.env[key] = value;
const terminal = new RecordingTerminal();
const tui = new TuiAltScreen(terminal);
tui.start();
const writes = terminal.events
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(writes.includes("\x1b[?1002h"), `${name} should enable button-motion tracking`);
assert.ok(!writes.includes("\x1b[?1003h"), `${name} should not enable all-motion tracking`);
assert.ok(writes.includes("\x1b[?1006h"), `${name} should enable SGR mouse encoding`);
tui.stop();
}
} finally {
for (const key of environmentKeys) {
const value = previousEnvironment.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
it("invokes the right-click paste handler only on Windows outside VS Code", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const termProgram = process.env.TERM_PROGRAM;
assert.ok(platformDescriptor);
const terminal = new VirtualTerminal();
let pasteCount = 0;
const tui = new TuiAltScreen(terminal, undefined, undefined, {
onRightClickPaste: () => {
pasteCount += 1;
},
});
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
delete process.env.TERM_PROGRAM;
tui.start();
terminal.sendInput("\x1b[<2;1;1M");
terminal.sendInput("\x1b[<2;1;1m");
assert.strictEqual(pasteCount, 1);
process.env.TERM_PROGRAM = "vscode";
terminal.sendInput("\x1b[<2;1;1M");
assert.strictEqual(pasteCount, 1);
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
delete process.env.TERM_PROGRAM;
terminal.sendInput("\x1b[<2;1;1M");
assert.strictEqual(pasteCount, 1);
} finally {
tui.stop();
Object.defineProperty(process, "platform", platformDescriptor);
if (termProgram === undefined) delete process.env.TERM_PROGRAM;
else process.env.TERM_PROGRAM = termProgram;
}
});
it("reveals an auto scrollbar when the pointer enters its hidden track", async () => {
const terminal = new RecordingTerminal(10, 5);
const tui = new TuiAltScreen(terminal);
const scrollView = new ScrollView(
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ primary: true, scrollbar: "auto", scrollbarHideDelayMs: 20 },
);
tui.setLayoutRoot(scrollView);
tui.start();
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, false);
terminal.sendInput("\x1b[<35;10;3M");
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, true);
assert.strictEqual(scrollView.isScrollbarActive, true);
assert.ok(terminal.getViewport().some((line) => /[│█]/.test(line)));
terminal.sendInput("\x1b[<35;9;3M");
await new Promise((resolve) => setTimeout(resolve, 40));
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, false);
tui.stop();
});
it("jumps to a scrollbar track position and continues dragging from there", async () => {
const terminal = new RecordingTerminal(10, 10);
const tui = new TuiAltScreen(terminal);
const scrollView = new ScrollView(
new Text(Array.from({ length: 50 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ primary: true, scrollbar: "always" },
);
tui.setLayoutRoot(scrollView);
tui.start();
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 0);
terminal.sendInput("\x1b[<0;10;6M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 20);
terminal.sendInput("\x1b[<32;10;10M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 40);
terminal.sendInput("\x1b[<0;10;10m");
await terminal.waitForRender();
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")));
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 });
const inner = new ScrollView(new Text("i1\ni2\ni3\ni4\ni5\ni6", 0, 0));
const outer = new ScrollView(
new VStack([{ component: inner, basis: 2 }, new Text("tail1\ntail2\ntail3\ntail4\ntail5", 0, 0)]),
{ primary: true },
);
tui.setLayoutRoot(outer);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<65;1;1M");
await terminal.waitForRender();
assert.strictEqual(inner.scrollTop, 3);
assert.strictEqual(outer.scrollTop, 0);
terminal.sendInput("\x1b[<65;1;1M");
await terminal.waitForRender();
assert.strictEqual(inner.scrollTop, 4);
assert.strictEqual(outer.scrollTop, 2);
tui.stop();
});
it("supports configurable keyboard viewport navigation with four rows of page overlap", async () => {
const terminal = new VirtualTerminal(20, 8);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[57421u");
terminal.sendInput("\x1b[57421;1:3u");
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("\x1b[57422u");
terminal.sendInput("\x1b[57422;1:3u");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"],
);
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("\x1b[8^");
await terminal.waitForRender();
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"],
);
tui.stop();
});
it("searches normalized rendered transcript text across rows", () => {
assert.deepStrictEqual(findAltScreenSearchMatches(["alpha QUICK", "brown fox"], "quick brown"), [
{
segments: [
{ row: 0, startCol: 6, endCol: 11 },
{ row: 1, startCol: 0, endCol: 5 },
],
},
]);
});
it("maps normalized ASCII and Unicode search matches back to rendered columns", () => {
assert.deepStrictEqual(findAltScreenSearchMatches(["\x1b[31mfoo bar\x1b[0m", "A界🙂éZ"], "oo bar\nA界🙂é"), [
{
segments: [
{ row: 0, startCol: 1, endCol: 3 },
{ row: 0, startCol: 5, endCol: 8 },
{ row: 1, startCol: 0, endCol: 6 },
],
},
]);
});
it("reuses indexed transcript matches until the query or rendered lines change", () => {
const index = new AltScreenSearchIndex();
const initial = index.search(["alpha needle", "omega"], "needle");
assert.strictEqual(initial.changed, true);
assert.strictEqual(initial.matches.length, 1);
const cached = index.search(["alpha needle", "omega"], "needle");
assert.strictEqual(cached.changed, false);
assert.strictEqual(cached.matches, initial.matches);
const changedQuery = index.search(["alpha needle", "omega"], "omega");
assert.strictEqual(changedQuery.changed, true);
assert.notStrictEqual(changedQuery.matches, initial.matches);
assert.deepStrictEqual(changedQuery.matches[0]?.segments, [{ row: 1, startCol: 0, endCol: 5 }]);
const changedLines = index.search(["alpha needle", "no match"], "omega");
assert.strictEqual(changedLines.changed, true);
assert.deepStrictEqual(changedLines.matches, []);
});
it("renders transcript search with a muted placeholder and right-aligned controls", () => {
const component = new AltScreenSearchComponent(() => {});
const rendered = component.render(48);
const lines = rendered.map((line) => stripTerminalSequences(line));
assert.strictEqual(lines.length, 3);
assert.ok(lines.every((line) => visibleWidth(line) === 48));
assert.match(lines[0] ?? "", /^┌─+┐$/);
assert.match(lines[1] ?? "", /^│ Find in transcript +│$/);
assert.ok(rendered[1]?.includes("\x1b[2m"));
assert.match(lines[2] ?? "", /^└─+ ↑ Shift\+Enter · ↓ Enter ─┘$/);
const controls = lines[2] ?? "";
assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("↑")), -1);
assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("Shift+Enter") + 5), -1);
assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("·")), undefined);
assert.strictEqual(component.getNavigationDirectionAt(2, controls.indexOf("↓")), 1);
assert.strictEqual(component.getNavigationDirectionAt(2, controls.lastIndexOf("Enter") + 2), 1);
component.handleInput("n");
component.setResult(0, 2);
const populatedRender = component.render(48);
const populated = populatedRender.map((line) => stripTerminalSequences(line));
assert.ok(populated[1]?.includes("n"));
assert.ok(populated[1]?.includes("1/2"));
assert.ok(populatedRender[1]?.includes("\x1b[2m 1/2 \x1b[22m"));
assert.ok(!populated.some((line) => line.includes("Find in transcript")));
});
it("navigates transcript search with hoverable arrow buttons and toggles it with its shortcut", async () => {
const terminal = new RecordingTerminal(120, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
searchNavigationButtonStyle: (text, hovered) => `${hovered ? "\x1b[45m" : "\x1b[44m"}${text}\x1b[49m`,
});
tui.addChild(new Text("needle one\nmiddle\nneedle two\nend", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[102;6u");
terminal.sendInput("needle");
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport.some((line) => line.includes("1/2")));
assert.ok(viewport.some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
let arrowRow = viewport.findIndex((line) => line.includes("↑") && line.includes("↓"));
let arrowColumn = viewport[arrowRow]?.lastIndexOf("Enter") ?? -1;
assert.ok(arrowRow >= 0 && arrowColumn >= 0);
const hoverEventCount = terminal.events.length;
terminal.sendInput(`\x1b[<35;${arrowColumn + 1};${arrowRow + 1}M`);
await terminal.waitForRender();
assert.ok(
terminal.events
.slice(hoverEventCount)
.some((event) => event.type === "write" && event.data.includes("\x1b[45m↓ Enter\x1b[49m")),
);
terminal.sendInput(`\x1b[<0;${arrowColumn + 1};${arrowRow + 1}M`);
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport.some((line) => line.includes("2/2")));
assert.ok(viewport.some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
arrowRow = viewport.findIndex((line) => line.includes("↑") && line.includes("↓"));
arrowColumn = (viewport[arrowRow]?.indexOf("Shift+Enter") ?? -3) + 3;
assert.ok(arrowRow >= 0 && arrowColumn >= 0);
terminal.sendInput(`\x1b[<0;${arrowColumn + 1};${arrowRow + 1}M`);
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("1/2")));
assert.ok(terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
terminal.sendInput("\x1b[102;6u");
await terminal.waitForRender();
assert.ok(!terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
tui.stop();
});
it("does not treat transcript box drawing as search navigation buttons", async () => {
const terminal = new VirtualTerminal(80, 10);
const tui = new TuiAltScreen(terminal);
tui.addChild(
new Text(
[
"needle one",
"middle",
"needle two",
"filler",
"┌────────────────────────────────────────┐",
"│ box │",
"└────────────────────────────────────────┘",
"end",
].join("\n"),
0,
0,
),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[102;6u");
terminal.sendInput("needle");
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport.some((line) => line.includes("1/2")));
assert.ok(!viewport.some((line) => line.includes("2/2")));
const boxBottomRow = viewport.findIndex((line) => line.startsWith("└"));
assert.ok(boxBottomRow >= 0);
terminal.sendInput(`\x1b[<0;24;${boxBottomRow + 1}M`);
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport.some((line) => line.includes("1/2")));
assert.ok(!viewport.some((line) => line.includes("2/2")));
tui.stop();
});
it("uses configured styles for current and non-current search matches", async () => {
const terminal = new RecordingTerminal(60, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
searchMatchStyle: (text) => `\x1b[41m${text}\x1b[49m`,
searchCurrentMatchStyle: (text) => `\x1b[42m${text}\x1b[49m`,
});
tui.addChild(new Text("needle first\nmiddle\nneedle second\nend", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[102;6u");
terminal.sendInput("needle");
await terminal.waitForRender();
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[42mneedle\x1b[49m")),
);
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[41mneedle\x1b[49m")),
);
tui.stop();
});
it("searches the transcript with Ctrl+Shift+F and restores editor focus on close", async () => {
const terminal = new RecordingTerminal(60, 8);
const tui = new TuiAltScreen(terminal);
const transcriptText = new Text(
Array.from({ length: 12 }, (_, index) => {
if (index === 4) return "line 5 needle one";
if (index === 9) return "line 10 needle two";
return `line ${index + 1}`;
}).join("\n"),
0,
0,
);
const transcript = new ScrollView(transcriptText, { follow: "end", primary: true });
const editorInputs: string[] = [];
const editor = {
focused: false,
render: () => ["editor"],
invalidate: () => {},
handleInput: (data: string) => editorInputs.push(data),
};
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: editor, basis: 1, shrink: 0 },
]),
);
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[102;6u");
terminal.sendInput("needle");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
assert.ok(terminal.getViewport().some((line) => line.includes("2/2")));
assert.ok(terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two")));
assert.deepStrictEqual(editorInputs, []);
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[1;7mneedle\x1b[22;27m")),
);
for (let index = 0; index < 6; index++) terminal.sendInput("\x1b[<64;1;4M");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 0);
assert.ok(terminal.getViewport().some((line) => line.includes("needle") && line.includes("2/2")));
terminal.sendInput("\x07");
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("1/2")));
assert.ok(terminal.getViewport().some((line) => line.includes("line 5 needle one")));
terminal.sendInput("\x1b[103;6u");
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("2/2")));
assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two")));
terminal.sendInput("\x1b");
terminal.sendInput("x");
await terminal.waitForRender();
assert.ok(!terminal.getViewport().some((line) => line.includes("↑ Shift+Enter · ↓ Enter")));
assert.deepStrictEqual(editorInputs, ["x"]);
tui.stop();
});
it("scrolls the transcript by half a page with custom bindings", async () => {
const originalKeybindings = getKeybindings();
const terminal = new VirtualTerminal(20, 10);
const tui = new TuiAltScreen(terminal);
setKeybindings(
new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.altScreen.halfPageUp": "ctrl+u",
"tui.altScreen.halfPageDown": "ctrl+d",
}),
);
try {
tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 20);
terminal.sendInput("\x15");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 15);
terminal.sendInput("\x04");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 20);
} finally {
tui.stop();
setKeybindings(originalKeybindings);
}
});
it("scrolls the transcript by one line with custom bindings", async () => {
const originalKeybindings = getKeybindings();
const terminal = new VirtualTerminal(20, 10);
const tui = new TuiAltScreen(terminal);
setKeybindings(
new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.altScreen.lineUp": "ctrl+y",
"tui.altScreen.lineDown": "ctrl+e",
}),
);
try {
tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 20);
terminal.sendInput("\x19");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 19);
terminal.sendInput("\x05");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 20);
} finally {
tui.stop();
setKeybindings(originalKeybindings);
}
});
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(
new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true },
);
const editorInputs: string[] = [];
const editor = {
focused: false,
render: () => ["editor"],
invalidate: () => {},
handleInput: (data: string) => editorInputs.push(data),
};
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: editor, basis: 1, shrink: 0 },
]),
);
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
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);
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);
terminal.sendInput("\x1b[6~");
await terminal.waitForRender();
assert.strictEqual(transcript.scrollTop, 1);
assert.deepStrictEqual(editorInputs, editorKeys);
tui.stop();
});
it("jumps between OSC 133 semantic prompt markers", async () => {
const terminal = new VirtualTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
tui.addChild(
new Text(
[1, 2, 3, 4].flatMap((message) => [`${OSC133_ZONE_START}message ${message}`, "detail"]).join("\n"),
0,
0,
),
);
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 5);
terminal.sendInput("\x1b[57419;6u");
terminal.sendInput("\x1b[57419;6:3u");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 4);
assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3");
terminal.sendInput("\x1b[1;6A");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 2);
assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 2");
terminal.sendInput("\x1b[57420;6u");
terminal.sendInput("\x1b[57420;6:3u");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 4);
assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3");
terminal.sendInput("\x1b[1;6B");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 5);
assert.strictEqual(terminal.getViewport()[1]?.trimEnd(), "message 4");
assert.strictEqual(tui.isFollowingOutput, true);
tui.stop();
});
it("does not emit Kitty graphics commands or OSC 133 zones in iTerm2", async () => {
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
tui.addChild({
render: () => ["\x1b]133;B\x07\x1b]133;C\x07\x1b]133;A\x07content"],
invalidate: () => {},
});
tui.addChild(
new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ filename: "example.png" },
{ widthPx: 10, heightPx: 10 },
),
);
tui.start();
await terminal.waitForRender();
tui.stop();
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b_G")));
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]133;")));
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]1337;File=")));
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("[Image:")));
} finally {
resetCapabilitiesCache();
}
});
it("clears stale iTerm2 image placements when they leave the viewport", async () => {
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
const imageLine = "\x1b]1337;File=inline=1;width=2;height=auto:AAAA\x07";
tui.addChild({
render: () => [imageLine, "", "", "after", "more", "end"],
invalidate: () => {},
});
tui.start();
await terminal.waitForRender();
tui.scrollToTop();
await terminal.waitForRender();
const eventCount = terminal.events.length;
tui.scrollBy(1);
await terminal.waitForRender();
assert.ok(
terminal.events.slice(eventCount).some((event) => event.type === "write" && event.data.includes("\x1b[2J")),
);
tui.stop();
} finally {
resetCapabilitiesCache();
}
});
it("crops a Kitty image whose first line is above the viewport", async () => {
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
const imageId = 123;
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false });
registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 });
tui.addChild({
render: () => ["before", imageLine, "", "", "after", "end"],
invalidate: () => {},
});
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 3);
assert.ok(
terminal.events.some(
(event) => event.type === "write" && event.data.includes("i=123") && event.data.includes("y=66,h=34,r=1"),
),
);
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 {
const terminal = new RecordingTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const label = new Text("left", 0, 0);
const image = new Image(
"A".repeat(8192),
"image/png",
{ fallbackColor: (value) => value },
{},
{ widthPx: 100, heightPx: 100 },
);
const header = new Text("header", 0, 0);
const row = new HStack([
{ component: label, basis: 10 },
{ component: image, basis: 10 },
]);
tui.setLayoutRoot(
new VStack([
{ component: header, basis: "auto" },
{ component: row, basis: 4 },
]),
);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b_Ga=T")));
const eventCount = terminal.events.length;
label.setText("changed");
header.setText("header\nsecond");
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"));
assert.ok(redrawWrites.length < 2000, `expected placement-only redraw, got ${redrawWrites.length} bytes`);
assert.ok(terminal.getViewport().some((line) => line.trimEnd() === "changed"));
tui.stop();
} finally {
resetCapabilitiesCache();
}
});
it("retains recently offscreen Kitty images for placement-only reuse", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
const imageId = 321;
const imageLine = encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false });
registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 100, heightPx: 50 });
tui.setLayoutRoot(
new ScrollView(
{
render: () => [imageLine, "after"],
invalidate: () => {},
},
{ primary: true },
),
);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b_Ga=T")));
const eventCount = terminal.events.length;
tui.scrollBy(1);
await terminal.waitForRender();
tui.scrollBy(-1);
await terminal.waitForRender();
const reentryWrites = terminal.events
.slice(eventCount)
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(reentryWrites.includes("\x1b_Ga=p,q=2"));
assert.ok(!reentryWrites.includes("\x1b_Ga=T"));
assert.ok(!reentryWrites.includes(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`));
tui.stop();
} finally {
resetCapabilitiesCache();
}
});
it("evicts the least recently visible Kitty image when the cache is full", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
const firstImageId = 500;
const imageLines = Array.from({ length: 18 }, (_, index) => {
const imageId = firstImageId + index;
registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 100, heightPx: 50 });
return encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false });
});
tui.setLayoutRoot(
new ScrollView(
{
render: () => imageLines,
invalidate: () => {},
},
{ primary: true },
),
);
tui.start();
await terminal.waitForRender();
for (let index = 1; index < imageLines.length; index++) {
tui.scrollBy(1);
await terminal.waitForRender();
}
assert.ok(
terminal.events.some(
(event) => event.type === "write" && event.data.includes(`\x1b_Ga=d,d=I,i=${firstImageId},q=2\x1b\\`),
),
);
const eventCount = terminal.events.length;
tui.scrollToTop();
await terminal.waitForRender();
const reentryWrites = terminal.events
.slice(eventCount)
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(reentryWrites.includes("\x1b_Ga=T"));
tui.stop();
} finally {
resetCapabilitiesCache();
}
});
it("evicts offscreen Kitty images when decoded raster memory exceeds the cache quota", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
try {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
const firstImageId = 600;
const imageLines = Array.from({ length: 4 }, (_, index) => {
const imageId = firstImageId + index;
registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 3840, heightPx: 2160 });
return encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false });
});
tui.setLayoutRoot(
new ScrollView(
{
render: () => imageLines,
invalidate: () => {},
},
{ primary: true },
),
);
tui.start();
await terminal.waitForRender();
for (let index = 1; index < imageLines.length; index++) {
tui.scrollBy(1);
await terminal.waitForRender();
}
assert.ok(
terminal.events.some(
(event) => event.type === "write" && event.data.includes(`\x1b_Ga=d,d=I,i=${firstImageId},q=2\x1b\\`),
),
);
tui.stop();
} finally {
resetCapabilitiesCache();
}
});
it("opens an OSC 8 hyperlink with specific or generic release codes, but not on drag", async () => {
const terminal = new RecordingTerminal(20, 3);
const openedUrls: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
openUrl: (url) => openedUrls.push(url),
});
const url = "https://example.com/path?q=1";
const belUrl = "https://example.com/bel";
const emojiUrl = "https://example.com/emoji";
tui.addChild(
new Text(
`${hyperlink("link", url)}\n\x1b]8;;${belUrl}\x07link\x1b]8;;\x07\n${hyperlink("🙂", emojiUrl)}`,
0,
0,
),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;2;1M");
terminal.sendInput("\x1b[<3;2;1m");
await terminal.waitForRender();
assert.deepStrictEqual(openedUrls, [url]);
terminal.sendInput("\x1b[<0;2;2M");
terminal.sendInput("\x1b[<0;2;2m");
await terminal.waitForRender();
assert.deepStrictEqual(openedUrls, [url, belUrl]);
terminal.sendInput("\x1b[<0;2;3M");
terminal.sendInput("\x1b[<0;2;3m");
await terminal.waitForRender();
assert.deepStrictEqual(openedUrls, [url, belUrl, emojiUrl]);
terminal.sendInput("\x1b[<0;2;1M");
terminal.sendInput("\x1b[<32;4;1M");
terminal.sendInput("\x1b[<0;4;1m");
await terminal.waitForRender();
assert.deepStrictEqual(openedUrls, [url, belUrl, emojiUrl]);
tui.stop();
});
it("selects visible text with the mouse and copies it with OSC 52 after a generic release", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("\x1b[1mal\x1b[0mpha\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[<3;4;2m");
await terminal.waitForRender();
const expectedClipboardSequence = `\x1b]52;c;${Buffer.from("alpha\nbeta").toString("base64")}\x07`;
const clipboardWrites = terminal.events.filter(
(event) => event.type === "write" && event.data.includes("\x1b]52;c;"),
);
assert.ok(
clipboardWrites.some((event) => event.type === "write" && event.data.includes(expectedClipboardSequence)),
JSON.stringify(clipboardWrites),
);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes("al\x1b[0m\x1b[7mpha")),
"selection inverse must be reapplied after a reset inside the selection",
);
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
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[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
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.deepStrictEqual(copied, ["alpha\nbeta"]);
assert.ok(
terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")),
"must not emit OSC 52 when a copySelection handler is provided",
);
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
tui.stop();
});
it("leaves selections visible without copying when copyOnSelect is disabled", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copyOnSelect: false,
copySelection: async (text) => {
copied.push(text);
return true;
},
});
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.deepStrictEqual(copied, []);
assert.strictEqual(tui.hasActiveSelection(), true);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
assert.ok(terminal.getViewport().every((line) => !line.includes("Copied!")));
tui.stop();
});
it("copies an active selection programmatically", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.hasActiveSelection(), false);
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false);
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
assert.strictEqual(tui.hasActiveSelection(), true);
copied.length = 0;
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), true);
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
assert.ok(terminal.getViewport().some((line) => line.includes("Copied!")));
tui.stop();
});
it("flashes an error when the injected copySelection handler fails", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async () => false,
});
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.ok(terminal.getViewport().some((line) => line.includes("Copy failed")));
assert.ok(
terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")),
"must not emit OSC 52 when a copySelection handler is provided",
);
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);
tui.addChild(new Text("foo bar", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<0;1;1m");
terminal.sendInput("\x1b[<0;3;1M");
await terminal.waitForRender();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo\x1b[27m")));
tui.stop();
});
it("coalesces slash and hyphen separated segments for double-click word selection", async () => {
for (const { line, needle } of [
{ line: "extensions/starline/fixed-editor/compositor.ts", needle: "starline" },
{ line: "earendil-works/pi-tui", needle: "works" },
]) {
const copied: string[] = [];
const terminal = new RecordingTerminal(80, 1);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copySelection: async (text) => {
copied.push(text);
return true;
},
});
tui.addChild(new Text(line, 0, 0));
tui.start();
await terminal.waitForRender();
const oneBasedClickColumn = line.indexOf(needle) + 1;
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1M`);
terminal.sendInput(`\x1b[<0;${oneBasedClickColumn};1m`);
await terminal.waitForRender();
assert.deepStrictEqual(copied, [line]);
tui.stop();
}
});
it("highlights a complete whitespace segment during a word drag", async () => {
const terminal = new RecordingTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("foo bar", 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<0;1;1m");
terminal.sendInput("\x1b[<0;2;1M");
terminal.sendInput("\x1b[<32;4;1M");
await terminal.waitForRender();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo \x1b[27m")));
tui.stop();
});
it("selects whole words on double click, extends word drags, and selects lines on triple click", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("zero alpha beta\ngamma delta", 0, 0));
tui.start();
await terminal.waitForRender();
// The second click lands on a different character in alpha.
terminal.sendInput("\x1b[<0;6;1M");
terminal.sendInput("\x1b[<0;6;1m");
terminal.sendInput("\x1b[<0;10;1M");
terminal.sendInput("\x1b[<0;10;1m");
await terminal.waitForRender();
const alpha = `\x1b]52;c;${Buffer.from("alpha").toString("base64")}\x07`;
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(alpha)));
// A double-click drag includes each word touched, rather than partial words.
terminal.sendInput("\x1b[<0;12;1M");
terminal.sendInput("\x1b[<0;12;1m");
terminal.sendInput("\x1b[<0;14;1M");
terminal.sendInput("\x1b[<32;3;2M");
terminal.sendInput("\x1b[<0;3;2m");
await terminal.waitForRender();
const words = `\x1b]52;c;${Buffer.from("beta\ngamma").toString("base64")}\x07`;
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(words)));
terminal.sendInput("\x1b[<0;7;2M");
terminal.sendInput("\x1b[<0;7;2m");
terminal.sendInput("\x1b[<0;9;2M");
terminal.sendInput("\x1b[<0;9;2m");
terminal.sendInput("\x1b[<0;11;2M");
terminal.sendInput("\x1b[<0;11;2m");
await terminal.waitForRender();
const line = `\x1b]52;c;${Buffer.from("gamma delta").toString("base64")}\x07`;
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(line)));
tui.stop();
});
it("does not repaint idle or zero-width selections on focus loss", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
tui.start();
await terminal.waitForRender();
const writeCount = () => terminal.events.filter((event) => event.type === "write").length;
const clipboardWriteCount = () =>
terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length;
const idleWriteCount = writeCount();
terminal.sendInput("\x1b[O");
terminal.sendInput("\x1b[I");
await terminal.waitForRender();
assert.strictEqual(writeCount(), idleWriteCount);
// A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it.
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<0;1;1m");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.strictEqual(clipboardWriteCount(), 0);
// Losing focus after a press without a drag cancels the press without repainting.
terminal.sendInput("\x1b[<0;1;3M");
await terminal.waitForRender();
const pressedWriteCount = writeCount();
terminal.sendInput("\x1b[O");
terminal.sendInput("\x1b[I");
await terminal.waitForRender();
assert.strictEqual(writeCount(), pressedWriteCount);
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.strictEqual(clipboardWriteCount(), 0);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004h")));
tui.stop();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l")));
});
it("clears an active visible selection on focus loss and ignores orphan events", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
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");
await terminal.waitForRender();
const focusLossEventCount = terminal.events.length;
terminal.sendInput("\x1b[O");
terminal.sendInput("\x1b[I");
await terminal.waitForRender();
const focusLossWrites = terminal.events
.slice(focusLossEventCount)
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(focusLossWrites.includes("alpha"));
assert.ok(focusLossWrites.includes("beta"));
assert.ok(!focusLossWrites.includes("\x1b[7m"));
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")));
tui.stop();
});
it("retains a completed visible selection across focus changes", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
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();
const completedWriteCount = terminal.events.filter((event) => event.type === "write").length;
terminal.sendInput("\x1b[O");
terminal.sendInput("\x1b[I");
await terminal.waitForRender();
assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, completedWriteCount);
const redrawEventCount = terminal.events.length;
tui.renderNow(true);
const redrawWrites = terminal.events
.slice(redrawEventCount)
.filter((event): event is { type: "write"; data: string } => event.type === "write")
.map((event) => event.data)
.join("");
assert.ok(redrawWrites.includes("alpha"));
assert.ok(redrawWrites.includes("beta"));
assert.ok(redrawWrites.includes("\x1b[7m"));
tui.stop();
});
it("stacks flash messages and collapses them as they expire", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("one\ntwo\nthree\nfour", 0, 0));
tui.start();
await terminal.waitForRender();
tui.flash("First", 80);
tui.flash("Second", 500);
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.endsWith(" First "));
assert.ok(viewport[1]?.endsWith(" Second "));
await new Promise((resolve) => setTimeout(resolve, 100));
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport[0]?.endsWith(" Second "));
assert.ok(!viewport.some((line) => line.includes("First")));
tui.stop();
});
it("auto-scrolls and extends a drag selection held at the viewport edge", async () => {
const terminal = new RecordingTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 6);
terminal.sendInput("\x1b[<0;1;3M");
terminal.sendInput("\x1b[<32;1;1M");
await new Promise((resolve) => setTimeout(resolve, 130));
await terminal.waitForRender();
const selectionTop = tui.viewportTop;
assert.ok(selectionTop < 6, `expected auto-scroll above row 6, got ${selectionTop}`);
terminal.sendInput("\x1b[<0;1;1m");
await terminal.waitForRender();
const selectedLines = Array.from({ length: 8 - selectionTop }, (_, index) => `line ${selectionTop + index + 1}`);
selectedLines.push("l");
const expectedClipboardSequence = `\x1b]52;c;${Buffer.from(selectedLines.join("\n")).toString("base64")}\x07`;
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes(expectedClipboardSequence)),
JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))),
);
tui.stop();
});
it("snaps mouse selection to CJK, emoji, and combining grapheme boundaries", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("A界🙂éZ", 0, 0));
tui.start();
await terminal.waitForRender();
const wideSelection = `\x1b]52;c;${Buffer.from("界🙂").toString("base64")}\x07`;
terminal.sendInput("\x1b[<0;3;1M");
terminal.sendInput("\x1b[<32;4;1M");
terminal.sendInput("\x1b[<0;4;1m");
await terminal.waitForRender();
assert.strictEqual(
terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length,
1,
);
terminal.sendInput("\x1b[<0;5;1M");
terminal.sendInput("\x1b[<32;2;1M");
terminal.sendInput("\x1b[<0;2;1m");
await terminal.waitForRender();
assert.strictEqual(
terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length,
2,
);
const combiningSelection = `\x1b]52;c;${Buffer.from("éZ").toString("base64")}\x07`;
terminal.sendInput("\x1b[<0;6;1M");
terminal.sendInput("\x1b[<32;7;1M");
terminal.sendInput("\x1b[<0;7;1m");
await terminal.waitForRender();
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(combiningSelection)));
tui.stop();
});
it("ignores horizontal trackpad wheel events", async () => {
const terminal = new VirtualTerminal(20, 4);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<66;1;1M");
terminal.sendInput("\x1b[<67;1;1M");
await terminal.waitForRender();
assert.strictEqual(tui.viewportTop, 4);
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 5", "line 6", "line 7", "line 8"],
);
tui.stop();
});
it("dispatches clicks to nested mouse regions without breaking drag selection", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let clicks = 0;
tui.addChild(
new MouseRegion(new Text("clickable\nselectable", 0, 0), (event) => {
if (event.type !== "click") return undefined;
clicks += 1;
return { handled: true };
}),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;2;1M");
terminal.sendInput("\x1b[<0;2;1m");
await terminal.waitForRender();
assert.strictEqual(clicks, 1);
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
assert.strictEqual(clicks, 1);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b]52;c;")));
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);
const events: string[] = [];
const component = {
render: () => ["control"],
invalidate: () => {},
handleMouse: (event: TuiMouseEvent) => {
events.push(event.type);
return event.type === "press" ? { handled: true, capture: true, focus: true } : { handled: true };
},
};
tui.addChild(component);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;5;2M");
terminal.sendInput("\x1b[<0;5;2m");
await terminal.waitForRender();
assert.deepStrictEqual(events, ["press", "drag", "release"]);
assert.strictEqual(tui.getFocusedComponent(), component);
tui.stop();
});
it("reports consecutive click counts to component-owned controls", async () => {
const terminal = new VirtualTerminal(20, 1);
const tui = new TuiAltScreen(terminal);
const clickCounts: number[] = [];
tui.addChild({
render: () => ["control"],
invalidate: () => {},
handleMouse: (event) => {
if (event.type === "press") return { handled: true };
if (event.type === "click") {
clickCounts.push(event.clickCount ?? 0);
return { handled: true };
}
return undefined;
},
});
tui.start();
await terminal.waitForRender();
for (let count = 0; count < 3; count++) {
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<0;1;1m");
}
await terminal.waitForRender();
assert.deepStrictEqual(clickCounts, [1, 2, 3]);
tui.stop();
});
it("does not rerender for handled no-op pointer motion", async () => {
const terminal = new RecordingTerminal(20, 2);
const tui = new TuiAltScreen(terminal);
let renderCount = 0;
tui.addChild({
render: () => {
renderCount += 1;
return ["hover target"];
},
invalidate: () => {},
handleMouse: (event) => (event.type === "move" ? { handled: true } : undefined),
});
tui.start();
await terminal.waitForRender();
const renderedBeforeMotion = renderCount;
const writesBeforeMotion = terminal.events.filter((event) => event.type === "write").length;
terminal.sendInput("\x1b[<35;1;1M");
await terminal.waitForRender();
assert.strictEqual(renderCount, renderedBeforeMotion);
assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, writesBeforeMotion);
tui.stop();
});
it("lets mouse-aware components consume wheel events before viewport scrolling", async () => {
const terminal = new VirtualTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
let wheelEvents = 0;
tui.addChild(
new MouseRegion(
new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
(event) => {
if (event.type !== "wheel") return undefined;
wheelEvents += 1;
return { handled: true };
},
),
);
tui.start();
await terminal.waitForRender();
const viewportTop = tui.viewportTop;
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
assert.strictEqual(wheelEvents, 1);
assert.strictEqual(tui.viewportTop, viewportTop);
tui.stop();
});
it("restores keyboard state before leaving alt mode and prints the full document", async () => {
const terminal = new RecordingTerminal(20, 3);
const tui = new TuiAltScreen(terminal);
tui.addChild(new Text("first\nsecond\nthird\nfourth\nfifth\nsixth", 0, 0));
tui.start();
await terminal.waitForRender();
tui.stop();
const startIndex = terminal.events.findIndex((event) => event.type === "start");
const altScreenEnterIndex = terminal.events.findIndex(
(event) => event.type === "write" && event.data.includes("\x1b[?1049h"),
);
const stopIndex = terminal.events.findIndex((event) => event.type === "stop");
const mouseDisableIndex = terminal.events.findIndex(
(event) => event.type === "write" && event.data.includes("\x1b[?1006l"),
);
const mainScreenRestoreIndex = terminal.events.findIndex(
(event) => event.type === "write" && event.data.includes("\x1b[?1049l"),
);
assert.ok(altScreenEnterIndex >= 0 && altScreenEnterIndex < startIndex);
assert.ok(mouseDisableIndex >= 0 && mouseDisableIndex < stopIndex);
assert.ok(mainScreenRestoreIndex > stopIndex);
const restoreEvent = terminal.events[mainScreenRestoreIndex];
assert.strictEqual(restoreEvent?.type, "write");
if (restoreEvent?.type === "write") {
assert.ok(restoreEvent.data.includes("first"));
assert.ok(restoreEvent.data.includes("second"));
assert.ok(restoreEvent.data.includes("third"));
assert.ok(restoreEvent.data.includes("fourth"));
assert.ok(restoreEvent.data.includes("fifth"));
assert.ok(restoreEvent.data.includes("sixth"));
assert.ok(restoreEvent.data.indexOf("first") < restoreEvent.data.indexOf("sixth"));
}
});
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));
const overlay = new InputOverlay();
tui.start();
await terminal.waitForRender();
const topBefore = tui.viewportTop;
const handle = tui.showOverlay(overlay);
await terminal.waitForRender();
assert.strictEqual(overlay.focused, true);
const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF"];
for (const key of keys) terminal.sendInput(key);
await terminal.waitForRender();
assert.deepStrictEqual(overlay.inputs, keys);
assert.strictEqual(tui.viewportTop, topBefore);
handle.hide();
await terminal.waitForRender();
terminal.sendInput("\x1b[5~");
await terminal.waitForRender();
assert.ok(tui.viewportTop < topBefore);
tui.stop();
});
it("keeps viewport scrolling when an overlay is not focused", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TuiAltScreen(terminal);
const editor = new InputOverlay();
tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0));
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
const topBefore = tui.viewportTop;
const hidden = tui.showOverlay(new InputOverlay());
hidden.setHidden(true);
const nonCapturing = new InputOverlay();
tui.showOverlay(nonCapturing, { nonCapturing: true });
const unfocused = new InputOverlay();
const unfocusedHandle = tui.showOverlay(unfocused);
unfocusedHandle.unfocus();
await terminal.waitForRender();
assert.strictEqual(nonCapturing.focused, false);
assert.strictEqual(unfocused.focused, false);
terminal.sendInput("\x1b[5~");
terminal.sendInput("\x1b[<64;10;3M");
await terminal.waitForRender();
assert.ok(tui.viewportTop < topBefore);
assert.deepStrictEqual(nonCapturing.inputs, []);
assert.deepStrictEqual(unfocused.inputs, []);
tui.stop();
});
it("keeps viewport scrolling while transcript search is focused", 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));
tui.start();
await terminal.waitForRender();
const topBefore = tui.viewportTop;
terminal.sendInput("\x1b[102;6u");
await terminal.waitForRender();
assert.ok(terminal.getViewport().some((line) => line.includes("↑ ↓")));
terminal.sendInput("\x1b[5~");
terminal.sendInput("\x1b[<64;1;4M");
await terminal.waitForRender();
assert.ok(tui.viewportTop < topBefore);
assert.ok(terminal.getViewport().some((line) => line.includes("↑ ↓")));
tui.stop();
});
});