1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 applyTerminalAttachStreamEvent,
4 INITIAL_TERMINAL_OUTPUT_CURSOR,
5 nextTerminalAttachSeedState,
6} from "@t3tools/client-runtime/state/terminal";
7import { synchronizeTerminalSession } from "../../components/ThreadTerminalDrawer";
8β
9import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core";
10import {
11 advanceTerminalSelectionClickSequence,
12 applyTerminalCopyEvent,
13 clearPrimedTerminalCopyInput,
14 isTerminalCompositionKey,
15 isTerminalCopyShortcut,
16 primeTerminalCopyInput,
17 resolveTerminalMouseData,
18 terminalLinkAtPositionWithRange,
19 terminalContentOriginY,
20 terminalSelectionAutoscrollRows,
21 terminalWheelDeltaRows,
22 GhosttyTerminalSurface,
23 type GhosttyTerminalSurfaceOptions,
24} from "./surface";
25β
26vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({
27 default: (await import("./vendor/ghostty-vt.wasm?inline")).default,
28}));
29vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({
30 default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default,
31}));
32β
33describe("GhosttyTerminalSurface visibility", () => {
34 const surfaces = new Set<GhosttyTerminalSurface>();
35β
36 function key(
37 surface: GhosttyTerminalSurface,
38 key: string,
39 code: string,
40 modifiers: Partial<
41 Pick<
42 KeyboardEvent,
43 | "altKey"
44 | "ctrlKey"
45 | "metaKey"
46 | "shiftKey"
47 | "isComposing"
48 | "repeat"
49 | "keyCode"
50 | "getModifierState"
51 >
52 > = {},
53 type = "keydown",
54 target: EventTarget = surface.input,
55 ) {
56 const event = Object.assign(new Event(type, { cancelable: true }), {
57 key,
58 code,
59 altKey: false,
60 ctrlKey: false,
61 metaKey: false,
62 shiftKey: false,
63 isComposing: false,
64 repeat: false,
65 keyCode: 0,
66 getModifierState: () => false,
67 ...modifiers,
68 });
69 target.dispatchEvent(event);
70 return event;
71 }
72β
73 function createHarness() {
74 vi.useFakeTimers();
75 const frames = new Map<number, FrameRequestCallback>();
76 const resizeTargets = new Map<(entries: { target: Element }[]) => void, Set<Element>>();
77 const paint = vi.fn((_operation: string, _args: ReadonlyArray<unknown>) => {});
78 let frameId = 0;
79 const requestFrame = vi.fn((callback: FrameRequestCallback) => {
80 frames.set(++frameId, callback);
81 return frameId;
82 });
83β
84 class TerminalTestElement extends EventTarget {
85 style: Record<string, string> = {};
86 parentElement: TerminalTestElement | null = null;
87 clientWidth = 168;
88 clientHeight = 104;
89 width = 300;
90 height = 150;
91 value = "";
92 private readonly captures = new Set<number>();
93β
94 setAttribute() {}
95 append(...children: TerminalTestElement[]) {
96 for (const child of children) child.parentElement = this;
97 }
98 replaceChildren(...children: TerminalTestElement[]) {
99 this.append(...children);
100 }
101 remove() {
102 this.parentElement = null;
103 }
104 getContext() {
105 return context;
106 }
107 focus() {
108 this.dispatchEvent(new Event("focus"));
109 }
110 setPointerCapture(pointerId: number) {
111 this.captures.add(pointerId);
112 }
113 hasPointerCapture(pointerId: number) {
114 return this.captures.has(pointerId);
115 }
116 releasePointerCapture(pointerId: number) {
117 this.captures.delete(pointerId);
118 }
119 getBoundingClientRect() {
120 return { left: 0, top: 0, right: 168, bottom: 104, width: 168, height: 104 };
121 }
122 }
123β
124 const canvas = new TerminalTestElement();
125 const mount = new TerminalTestElement();
126 const context = {
127 canvas,
128 beginPath() {},
129 clip() {},
130 getTransform: () => ({ a: 1, d: 1 }),
131 rect() {},
132 resetTransform() {},
133 restore() {},
134 save() {},
135 setTransform() {},
136 fillRect: (...args: number[]) => paint("fillRect", args),
137 strokeRect: (...args: number[]) => paint("strokeRect", args),
138 fillText: (...args: [string, number, number, number?]) => paint("fillText", args),
139 measureText: (text: string) => ({
140 width: text.length * 8,
141 actualBoundingBoxAscent: 9,
142 actualBoundingBoxDescent: 3,
143 }),
144 };
145 vi.stubGlobal("document", {
146 createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()),
147 fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }),
148 });
149 vi.stubGlobal(
150 "window",
151 Object.assign(new EventTarget(), {
152 devicePixelRatio: 1,
153 innerHeight: 104,
154 requestAnimationFrame: requestFrame,
155 cancelAnimationFrame: (id: number) => frames.delete(id),
156 setTimeout,
157 clearTimeout,
158 setInterval,
159 clearInterval,
160 matchMedia: () => Object.assign(new EventTarget(), { matches: false }),
161 }),
162 );
163 vi.stubGlobal(
164 "ResizeObserver",
165 class {
166 constructor(private readonly callback: (entries: { target: Element }[]) => void) {
167 resizeTargets.set(callback, new Set());
168 }
169 observe(target: Element) {
170 resizeTargets.get(this.callback)?.add(target);
171 }
172 unobserve(target: Element) {
173 resizeTargets.get(this.callback)?.delete(target);
174 }
175 disconnect() {
176 resizeTargets.delete(this.callback);
177 }
178 },
179 );
180 const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot");
181 const onData = vi.fn<(data: string) => void>();
182β
183 return {
184 canvas,
185 mount,
186 frames,
187 paint,
188 requestFrame,
189 snapshot,
190 onData,
191 get renderedSnapshot() {
192 const result = snapshot.mock.results.at(-1);
193 if (result?.type !== "return") throw new Error("No terminal snapshot was rendered");
194 return result.value;
195 },
196 flushFrame() {
197 const queued = [...frames.values()];
198 frames.clear();
199 for (const callback of queued) callback(0);
200 },
201 resize() {
202 for (const [callback, targets] of resizeTargets) {
203 callback([...targets].map((target) => ({ target })));
204 }
205 },
206 pointer(
207 type: string,
208 clientX: number,
209 buttons: number,
210 modifiers: boolean | Partial<Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">> = {},
211 button = 0,
212 clientY = 5,
213 ) {
214 canvas.dispatchEvent(
215 Object.assign(new Event(type, { cancelable: true }), {
216 clientX,
217 clientY,
218 pointerId: 1,
219 button,
220 buttons,
221 ...(typeof modifiers === "boolean" ? { shiftKey: modifiers } : modifiers),
222 }),
223 );
224 },
225 touch(type: string, clientY: number, pointerId = 2) {
226 canvas.dispatchEvent(
227 Object.assign(new Event(type, { cancelable: true }), {
228 clientX: 40,
229 clientY,
230 pointerId,
231 pointerType: "touch",
232 isPrimary: pointerId === 2,
233 button: 0,
234 buttons: type === "pointerup" ? 0 : 1,
235 shiftKey: false,
236 }),
237 );
238 },
239 async create(options: Partial<GhosttyTerminalSurfaceOptions> = {}) {
240 const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, {
241 theme: {
242 foreground: { r: 255, g: 255, b: 255 },
243 background: { r: 0, g: 0, b: 0 },
244 cursor: { r: 255, g: 255, b: 255 },
245 },
246 onData,
247 onResize() {},
248 onSelectionChange() {},
249 beforeKey: () => false,
250 onLinkActivate() {},
251 ...options,
252 get visible() {
253 return options.visible ?? true;
254 },
255 });
256 surfaces.add(surface);
257 return surface;
258 },
259 };
260 }
261β
262 afterEach(() => {
263 for (const surface of surfaces) surface.dispose();
264 surfaces.clear();
265 vi.useRealTimers();
266 vi.unstubAllGlobals();
267 vi.restoreAllMocks();
268 });
269β
270 it("preserves shell Ctrl+A and ordinary application navigation", async () => {
271 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
272 const harness = createHarness();
273 const surface = await harness.create({ beforeKey: () => true });
274 key(surface, "a", "KeyA", { ctrlKey: true });
275 key(surface, "PageUp", "PageUp");
276 key(surface, "PageDown", "PageDown");
277 key(surface, "Home", "Home");
278 key(surface, "End", "End");
279 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
280 "\x01",
281 "\x1b[5~",
282 "\x1b[6~",
283 "\x1b[H",
284 "\x1b[F",
285 ]);
286 expect(surface.hasSelection()).toBe(false);
287 });
288β
289 it("copies on Ctrl+Shift+C when a non-Latin layout reports another letter on KeyC", async () => {
290 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
291 const harness = createHarness();
292 const surface = await harness.create({ beforeKey: () => true });
293 Object.assign(surface.input, { select() {} });
294 const execCommand = vi.fn(() => true);
295 Object.assign(document, { execCommand });
296 surface.write("SELECTED_OUTPUT");
297 surface.selectAll();
298 // Russian layout: Cyrillic Es (U+0421) on the physical C key.
299 key(surface, "Π‘", "KeyC", { ctrlKey: true, shiftKey: true });
300 expect(execCommand).toHaveBeenCalledWith("copy");
301 expect(harness.onData).not.toHaveBeenCalled();
302 });
303β
304 it("skips middle-click selection paste when the desktop turned primary paste off", async () => {
305 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
306 const harness = createHarness();
307 const surface = await harness.create();
308 surface.write("echo hi");
309 surface.selectAll();
310 const pasteFromClipboard = vi.spyOn(surface, "pasteFromClipboard");
311β
312 let primaryPasteEnabled = false;
313 Object.assign(window, { desktopBridge: { getPrimaryPasteEnabled: () => primaryPasteEnabled } });
314 harness.pointer("pointerdown", 5, 4, {}, 1);
315 expect(pasteFromClipboard).not.toHaveBeenCalled();
316β
317 primaryPasteEnabled = true;
318 harness.pointer("pointerdown", 5, 4, {}, 1);
319 expect(pasteFromClipboard).toHaveBeenCalledOnce();
320 });
321β
322 it("leaves modified history keys with alternate-screen applications", async () => {
323 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
324 const harness = createHarness();
325 const surface = await harness.create({ beforeKey: () => true });
326 surface.write("\x1b[?1049happ");
327 key(surface, "PageUp", "PageUp", { shiftKey: true });
328 key(surface, "End", "End", { ctrlKey: true, shiftKey: true });
329 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual(["\x1b[5;2~", "\x1b[1;6F"]);
330 surface.selectAll();
331 expect(surface.getSelection()).toBe("app");
332 surface.write("\x1b[?1049l");
333 expect(surface.getSelection()).toBe("");
334 });
335β
336 it("suppresses local shortcut releases in Kitty sessions, including read-only observers", async () => {
337 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
338 const harness = createHarness();
339 const surface = await harness.create({ beforeKey: () => true });
340 surface.input.readOnly = true;
341 surface.write("history\x1b[>31u");
342 for (const [name, code, modifiers] of [
343 ["PageUp", "PageUp", { shiftKey: true }],
344 ["Home", "Home", { ctrlKey: true, shiftKey: true }],
345 ["a", "KeyA", { ctrlKey: true, shiftKey: true }],
346 ] as const) {
347 key(surface, name, code, modifiers);
348 key(surface, name, code, modifiers, "keyup");
349 }
350 expect(surface.getSelection()).toBe("history");
351 expect(harness.onData).not.toHaveBeenCalled();
352β
353 key(surface, "PageUp", "PageUp");
354 key(surface, "PageUp", "PageUp", {}, "keyup");
355 expect(harness.onData).toHaveBeenCalledTimes(2);
356 expect(harness.onData.mock.calls[1]?.[0]).toContain(":3");
357 });
358β
359 it("reports Kitty releases only for presses encoded by this surface", async () => {
360 const harness = createHarness();
361 const surface = await harness.create({ beforeKey: () => true });
362 surface.write("\x1b[>11u");
363β
364 key(surface, "a", "KeyA");
365 key(surface, "a", "KeyA", {}, "keyup");
366 key(surface, "Control", "ControlLeft", { ctrlKey: true });
367 // App capture stops Ctrl+D before it reaches the terminal input.
368 key(surface, "d", "KeyD", { ctrlKey: true }, "keydown", window);
369 key(surface, "d", "KeyD", { ctrlKey: true }, "keyup");
370 key(surface, "Control", "ControlLeft", {}, "keyup");
371β
372 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
373 "\x1b[97u",
374 "\x1b[97;1:3u",
375 "\x1b[57442;5u",
376 "\x1b[57442;1:3u",
377 ]);
378 });
379β
380 it("keeps a held Kitty press through focus changes and an app-consumed repeat", async () => {
381 const harness = createHarness();
382 let consumeRepeat = false;
383 const surface = await harness.create({ beforeKey: () => !consumeRepeat });
384 surface.write("\x1b[>11u");
385β
386 key(surface, "a", "KeyA");
387 key(surface, "a", "KeyA", { repeat: true });
388 surface.input.dispatchEvent(new Event("blur"));
389 window.dispatchEvent(new Event("blur"));
390 window.dispatchEvent(new Event("focus"));
391 surface.input.dispatchEvent(new Event("focus"));
392 consumeRepeat = true;
393 key(surface, "a", "KeyA", { repeat: true });
394 key(surface, "a", "KeyA", {}, "keyup");
395β
396 expect(harness.onData.mock.calls.map(([data]) => data)).toEqual([
397 "\x1b[97u",
398 "\x1b[97;1:2u",
399 "\x1b[97;1:3u",
400 ]);
401 });
402β
403 it("honors host interception, IME candidates, and AltGr before local shortcuts", async () => {
404 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
405 const harness = createHarness();
406 let allow = false;
407 const surface = await harness.create({ beforeKey: () => allow });
408 surface.write("hello\x1b[>31u");
409 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true });
410 key(surface, "a", "KeyA", {}, "keyup");
411 expect(surface.hasSelection()).toBe(false);
412 allow = true;
413 surface.input.value = "εθ£";
414 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, isComposing: true });
415 key(surface, "a", "KeyA", {}, "keyup");
416 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true, keyCode: 229 });
417 key(surface, "a", "KeyA", {}, "keyup");
418 key(surface, "a", "KeyA", {
419 ctrlKey: true,
420 shiftKey: true,
421 altKey: true,
422 getModifierState: (modifier) => modifier === "AltGraph",
423 });
424 key(surface, "a", "KeyA", {}, "keyup");
425 expect(surface.input.value).toBe("εθ£");
426 expect(surface.hasSelection()).toBe(false);
427 expect(harness.onData).not.toHaveBeenCalled();
428 });
429β
430 function paste(surface: GhosttyTerminalSurface, text: string) {
431 surface.input.dispatchEvent(
432 Object.assign(new Event("paste", { cancelable: true }), {
433 clipboardData: { getData: () => text },
434 }),
435 );
436 }
437β
438 it.each(["native", "clipboard"])("pastes once when %s arrives first", async (first) => {
439 const harness = createHarness();
440 const read = Promise.resolve("echo hi");
441 vi.stubGlobal("navigator", {
442 platform: "MacIntel",
443 clipboard: { readText: () => read },
444 });
445 const surface = await harness.create({ beforeKey: () => true });
446β
447 key(surface, "v", "KeyV", { metaKey: true });
448 if (first === "native") paste(surface, "echo hi");
449 await read;
450 if (first === "clipboard") paste(surface, "echo hi");
451β
452 expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
453 });
454β
455 it.each(["V keyup", "blur", "Meta keyup without V keyup"])(
456 "accepts a later identical menu paste after %s",
457 async (end) => {
458 const harness = createHarness();
459 const read = Promise.resolve("echo hi");
460 vi.stubGlobal("navigator", {
461 platform: "MacIntel",
462 clipboard: { readText: () => read },
463 });
464 const surface = await harness.create({ beforeKey: () => true });
465β
466 key(surface, "v", "KeyV", { metaKey: true });
467 await read;
468 expect(harness.onData.mock.calls).toEqual([["echo hi"]]);
469 if (end === "blur") surface.input.dispatchEvent(new Event("blur"));
470 else if (end === "V keyup") key(surface, "v", "KeyV", { metaKey: true }, "keyup");
471 // macOS can omit V keyup while Command is held.
472 else key(surface, "Meta", "MetaLeft", {}, "keyup");
473 paste(surface, "echo hi");
474β
475 expect(harness.onData.mock.calls).toEqual([["echo hi"], ["echo hi"]]);
476 },
477 );
478β
479 it("keeps a selection through redraws until the selected text changes", async () => {
480 const harness = createHarness();
481 const surface = await harness.create();
482 const seed = nextTerminalAttachSeedState();
483 let state = applyTerminalAttachStreamEvent(seed, {
484 type: "snapshot",
485 snapshot: {
486 threadId: "thread",
487 terminalId: "term-1",
488 cwd: "/repo",
489 worktreePath: null,
490 status: "running",
491 pid: 123,
492 history: "* Working |",
493 exitCode: null,
494 exitSignal: null,
495 label: "Terminal",
496 updatedAt: "2026-10-20T00:00:00.000Z",
497 },
498 });
499 let cursor = synchronizeTerminalSession(surface, seed, state, INITIAL_TERMINAL_OUTPUT_CURSOR);
500 harness.flushFrame();
501 harness.pointer("pointerdown", 5, 1);
502 harness.pointer("pointermove", 85, 1);
503 harness.pointer("pointerup", 85, 0);
504 expect(surface.getSelection()).toBe("* Working |");
505β
506 // A TUI repaints its spinner line in place, the way Claude Code redraws.
507 const redraw = (data: string) => {
508 const previous = state;
509 state = applyTerminalAttachStreamEvent(state, {
510 type: "output",
511 threadId: "thread",
512 terminalId: "term-1",
513 data,
514 });
515 cursor = synchronizeTerminalSession(surface, previous, state, cursor);
516 };
517 redraw("\r\x1b[2K* Working |");
518 expect(surface.getSelection()).toBe("* Working |");
519β
520 redraw("\r\x1b[2K* Working /");
521 expect(surface.getSelection()).toBe("");
522 expect(surface.getSelectionPosition()).toBeNull();
523 });
524β
525 it("reports where a selection sits after output scrolls it", async () => {
526 const harness = createHarness();
527 const surface = await harness.create();
528 const rows = Array.from({ length: 10 }, (_, index) => `row ${index}`);
529 surface.write(`\x1b[?1049h\x1b[H${rows.join("\r\n")}`);
530 harness.flushFrame();
531 harness.pointer("pointerdown", 5, 1, {}, 0, 50);
532 harness.pointer("pointermove", 45, 1, {}, 0, 50);
533 harness.pointer("pointerup", 45, 0, {}, 0, 50);
534 const text = surface.getSelection();
535 const before = surface.getSelectionPosition();
536 expect(text).toMatch(/^row \d$/);
537β
538 surface.write("\r\nrow 10\r\nrow 11");
539 expect(surface.getSelection()).toBe(text);
540 expect(surface.getSelectionPosition()?.start.y).toBe(before!.start.y - 2);
541 });
542β
543 it("keeps a drag anchored to its text when output scrolls it mid-drag", async () => {
544 const harness = createHarness();
545 const surface = await harness.create();
546 const rows = Array.from({ length: 10 }, (_, index) => `row ${index}`);
547 surface.write(`\x1b[?1049h\x1b[H${rows.join("\r\n")}`);
548 harness.flushFrame();
549 harness.pointer("pointerdown", 5, 1, {}, 0, 50);
550 harness.pointer("pointermove", 45, 1, {}, 0, 50);
551 expect(surface.getSelection()).toBe("row 6");
552β
553 // The pointer stays over the row that output scrolls "row 8" into.
554 surface.write("\r\nrow 10\r\nrow 11");
555 harness.pointer("pointermove", 53, 1, {}, 0, 50);
556 expect(surface.getSelection()).toBe("row 6\nrow 7\nrow 8");
557 });
558β
559 it("does not activate a link replaced before pointer release", async () => {
560 const harness = createHarness();
561 const onLinkActivate = vi.fn();
562 const surface = await harness.create({ onLinkActivate });
563 surface.write("https://first.example");
564 harness.flushFrame();
565β
566 harness.pointer("pointerdown", 5, 1);
567 surface.write("\x1b[2J\x1b[Hhttps://second.example");
568 harness.flushFrame();
569 harness.pointer("pointerup", 5, 0);
570β
571 expect(onLinkActivate).not.toHaveBeenCalled();
572 });
573β
574 it.each([
575 { scenario: "missing", exists: false },
576 { scenario: "file", exists: true },
577 { scenario: "replaced", exists: true },
578 { scenario: "superseded", exists: false },
579 { scenario: "hidden", exists: false },
580 { scenario: "dragged", exists: true },
581 { scenario: "keyboard-selected", exists: false },
582 { scenario: "selection-cleared", exists: true },
583 { scenario: "output-cleaned", exists: true },
584 ])(
585 "validates a quick click without waiting for hover: $scenario",
586 async ({ scenario, exists }) => {
587 vi.stubGlobal("navigator", { platform: "Linux x86_64" });
588 const harness = createHarness();
589 let resolveValidation!: (exists: boolean) => void;
590 const validation = new Promise<boolean>((resolve) => {
591 resolveValidation = resolve;
592 });
593 const validateLink = vi.fn(() => validation);
594 const onLinkActivate = vi.fn();
595 const surface = await harness.create({
596 beforeKey: () => true,
597 canActivateLink: () => false,
598 validateLink,
599 onLinkActivate,
600 });
601 surface.write("src/main.ts");
602 harness.flushFrame();
603 surface.selectAll();
604 expect(surface.getSelection()).toBe("src/main.ts");
605 harness.pointer("pointerdown", 5, 1);
606 expect(validateLink).toHaveBeenCalledWith("src/main.ts");
607 if (scenario === "dragged") harness.pointer("pointermove", 45, 1);
608 harness.pointer("pointerup", 5, 0);
609 expect(onLinkActivate).not.toHaveBeenCalled();
610 if (scenario === "replaced") {
611 surface.write("\r\x1b[2Ksrc/other.ts");
612 harness.flushFrame();
613 }
614 if (scenario === "superseded") harness.pointer("pointerdown", 5, 1);
615 if (scenario === "hidden") {
616 surface.setVisible(false);
617 surface.setVisible(true);
618 }
619 if (scenario === "keyboard-selected")
620 key(surface, "a", "KeyA", { ctrlKey: true, shiftKey: true });
621 if (scenario === "selection-cleared") surface.clearSelection();
622 if (scenario === "output-cleaned") surface.clearSelection({ source: "output" });
623 resolveValidation(exists);
624 await validation;
625 expect(onLinkActivate).toHaveBeenCalledTimes(
626 scenario === "file" || scenario === "output-cleaned" ? 1 : 0,
627 );
628 if (scenario === "missing" || scenario === "selection-cleared")
629 expect(surface.getSelection()).toBe("");
630 if (
631 scenario === "file" ||
632 scenario === "hidden" ||
633 scenario === "superseded" ||
634 scenario === "keyboard-selected"
635 ) {
636 expect(surface.getSelection()).not.toBe("");
637 }
638 },
639 );
640β
641 it("only requests validation for a stable visible link under the pointer", async () => {
642 const harness = createHarness();
643 const onLinkHover = vi.fn();
644 const surface = await harness.create({ onLinkHover, canActivateLink: () => false });
645 surface.write("\x1b[?1049h\x1b[Hsrc/first.ts");
646 harness.flushFrame();
647 vi.advanceTimersByTime(250);
648 expect(onLinkHover).not.toHaveBeenCalled();
649β
650 harness.pointer("pointermove", 5, 0);
651 expect(onLinkHover).toHaveBeenLastCalledWith("src/first.ts");
652 onLinkHover.mockClear();
653 for (let index = 0; index < 100; index++) {
654 surface.write(`\r\x1b[2Ksrc/file-${index}.ts`);
655 harness.flushFrame();
656 vi.advanceTimersByTime(10);
657 }
658 vi.advanceTimersByTime(239);
659 expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]);
660 vi.advanceTimersByTime(1);
661 harness.flushFrame();
662 vi.advanceTimersByTime(1_000);
663 expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([["src/file-99.ts"]]);
664 onLinkHover.mockClear();
665β
666 harness.pointer("pointerleave", 5, 0);
667 surface.write("\r\x1b[2Ksrc/recent.ts");
668 harness.flushFrame();
669 vi.advanceTimersByTime(150);
670 harness.pointer("pointermove", 5, 0);
671 vi.advanceTimersByTime(99);
672 expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]);
673 vi.advanceTimersByTime(1);
674 expect(onLinkHover).toHaveBeenLastCalledWith("src/recent.ts");
675 onLinkHover.mockClear();
676β
677 harness.pointer("pointerleave", 5, 0);
678 surface.write("\r\x1b[2Ksrc/still.ts");
679 harness.flushFrame();
680 vi.advanceTimersByTime(250);
681 surface.write("\x1b[2;1Hother output");
682 harness.flushFrame();
683 harness.pointer("pointermove", 5, 0);
684 expect(onLinkHover).toHaveBeenLastCalledWith("src/still.ts");
685 onLinkHover.mockClear();
686β
687 surface.write("\x1b[H\x1b[2Ksrc/move.ts");
688 harness.flushFrame();
689 harness.pointer("pointermove", 45, 0);
690 vi.advanceTimersByTime(150);
691 surface.write("\r\x1b[2K src/move.ts");
692 harness.flushFrame();
693 vi.advanceTimersByTime(249);
694 expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]);
695 vi.advanceTimersByTime(1);
696 expect(onLinkHover).toHaveBeenLastCalledWith("src/move.ts");
697 onLinkHover.mockClear();
698β
699 for (const [index, cancel] of [
700 () => {
701 surface.write("\r\x1b[2Kplain text");
702 harness.flushFrame();
703 },
704 () => harness.pointer("pointerleave", 5, 0),
705 () => surface.setVisible(false),
706 () => surface.dispose(),
707 ].entries()) {
708 surface.setVisible(true);
709 surface.write(`\x1b[H\x1b[2Ksrc/next-${index}.ts`);
710 harness.flushFrame();
711 harness.pointer("pointermove", 5, 0);
712 cancel();
713 vi.advanceTimersByTime(250);
714 expect(onLinkHover.mock.calls.filter(([text]) => text !== null)).toEqual([]);
715 }
716 });
717β
718 it.each([false, true])(
719 "uses visibility %s if it changes while WASM initializes",
720 async (visible) => {
721 const harness = createHarness();
722 let markCreated!: () => void;
723 const created = new Promise<void>((resolve) => {
724 markCreated = resolve;
725 });
726 let finishLoading!: () => void;
727 const release = new Promise<void>((resolve) => {
728 finishLoading = resolve;
729 });
730 const create = GhosttyTerminalCore.create;
731 vi.spyOn(GhosttyTerminalCore, "create").mockImplementation(async (...args) => {
732 const core = await create(...args);
733 markCreated();
734 await release;
735 return core;
736 });
737 let currentVisible = !visible;
738 const pending = harness.create({
739 get visible() {
740 return currentVisible;
741 },
742 });
743 await created;
744 currentVisible = visible;
745 harness.paint.mockClear();
746 finishLoading();
747 const surface = await pending;
748β
749 expect(harness.snapshot).toHaveBeenCalledTimes(visible ? 1 : 0);
750 if (!visible) expect(harness.paint).not.toHaveBeenCalled();
751 surface.write("ready\x1b[5n");
752 expect(harness.onData).toHaveBeenCalledWith("\x1b[0n");
753 if (!visible) {
754 expect(harness.frames.size).toBe(0);
755 surface.setVisible(true);
756 } else {
757 harness.flushFrame();
758 }
759 expect(harness.renderedSnapshot.rowData[0]?.text).toContain("ready");
760 },
761 );
762});
763β
764const cell = (text: string): GhosttyCell => ({
765 text,
766 wide: 0,
767 foreground: { r: 255, g: 255, b: 255 },
768 background: { r: 0, g: 0, b: 0 },
769 bold: false,
770 italic: false,
771 invisible: false,
772 strikethrough: false,
773 overline: false,
774 underline: false,
775 selected: false,
776});
777β
778describe("terminalLinkAtPositionWithRange", () => {
779 it.each([
780 { text: "https://example.com/ζ₯ζ¬", cols: 80, end: { x: 23, y: 0 } },
781 { text: "src/ζ₯ζ¬/main.ts:3:1", cols: 80, end: { x: 19, y: 0 } },
782 { text: "https://example.com/π", cols: 80, end: { x: 21, y: 0 } },
783 { text: "https://example.com/ζ₯ζ¬", cols: 23, end: { x: 1, y: 1 } },
784 ])("keeps every cell of $text clickable at $cols columns", async ({ text, cols, end }) => {
785 const core = await GhosttyTerminalCore.create(
786 cols,
787 3,
788 8,
789 16,
790 {
791 foreground: { r: 255, g: 255, b: 255 },
792 background: { r: 0, g: 0, b: 0 },
793 cursor: { r: 255, g: 255, b: 255 },
794 },
795 () => {},
796 );
797 try {
798 core.write(text + " next");
799 const rows = core.snapshot().rowData;
800 for (let y = 0; y <= end.y; y += 1) {
801 const lastColumn = y === end.y ? end.x : cols - 2;
802 for (let x = 0; x <= lastColumn; x += 1) {
803 expect(terminalLinkAtPositionWithRange(rows, y, x)).toEqual({
804 text,
805 range: { start: { x: 0, y: 0 }, end },
806 });
807 }
808 }
809 expect(terminalLinkAtPositionWithRange(rows, end.y, end.x + 1)).toBeNull();
810 } finally {
811 core.dispose();
812 }
813 });
814β
815 it("activates a wrapped quoted Windows path from its spaces and position suffix", () => {
816 const row = (text: string, isWrapContinuation: boolean): GhosttyRow => ({
817 cells: Array.from(text, (character) => cell(character)),
818 text,
819 isWrapContinuation,
820 wrapsToNext: false,
821 });
822 const first = String.raw`"C:\Users\Jane `;
823 const second = String.raw`Doe\index.ts":12:7`;
824 const rows = [row(first, false), row(second, true)];
825 const expected = {
826 text: String.raw`C:\Users\Jane Doe\index.ts:12:7`,
827 range: { start: { x: 1, y: 0 }, end: { x: second.length - 1, y: 1 } },
828 };
829 expect(terminalLinkAtPositionWithRange(rows, 0, first.length - 1)).toEqual(expected);
830 expect(terminalLinkAtPositionWithRange(rows, 1, second.length - 1)).toEqual(expected);
831 expect(terminalLinkAtPositionWithRange(rows, 0, 0)).toBeNull();
832 });
833β
834 it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
835 const cells = [
836 cell("π"),
837 cell(""),
838 ...Array.from("https://t3.codes", (character) => cell(character)),
839 ];
840 const row: GhosttyRow = {
841 cells,
842 text: cells
843 .map((value) => value.text || " ")
844 .join("")
845 .trimEnd(),
846 isWrapContinuation: false,
847 wrapsToNext: false,
848 };
849β
850 expect(terminalLinkAtPositionWithRange([row], 0, 2)?.text).toBe("https://t3.codes");
851 expect(terminalLinkAtPositionWithRange([row], 0, cells.length - 1)?.text).toBe(
852 "https://t3.codes",
853 );
854 expect(terminalLinkAtPositionWithRange([row], 0, 0)).toBeNull();
855 expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({
856 start: { x: 2, y: 0 },
857 end: { x: cells.length - 1, y: 0 },
858 });
859 });
860β
861 it("uses shared path matching and reconstructs soft-wrapped links", () => {
862 const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({
863 cells: Array.from(text.padEnd(16), (character) => cell(character)),
864 text: text.trimEnd(),
865 isWrapContinuation,
866 wrapsToNext,
867 });
868 const rows = [
869 row("https://example.", false),
870 row("com/reference", true),
871 row("~/project/file", false),
872 row("C:\\repo\\file.ts", false),
873 row("PS C:\\repo\\dir 1", false, true),
874 row("\\test> ", true),
875 ];
876β
877 expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference");
878 expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference");
879 expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file");
880 expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts");
881 expect(terminalLinkAtPositionWithRange(rows, 4, 13)).toEqual({
882 text: "C:\\repo\\dir 1\\test",
883 range: { start: { x: 3, y: 4 }, end: { x: 4, y: 5 } },
884 });
885 expect(terminalLinkAtPositionWithRange(rows, 5, 2)?.text).toBe("C:\\repo\\dir 1\\test");
886 expect(terminalLinkAtPositionWithRange(rows, 5, 5)).toBeNull();
887 expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({
888 text: "https://example.com/reference",
889 range: {
890 start: { x: 0, y: 0 },
891 end: { x: 12, y: 1 },
892 },
893 });
894 });
895β
896 it("refuses links truncated at the viewport edges instead of mis-resolving", () => {
897 const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({
898 cells: Array.from(text.padEnd(16), (character) => cell(character)),
899 text: text.trimEnd(),
900 isWrapContinuation,
901 wrapsToNext,
902 });
903 const headCut = [row("ple.com/missing", true), row("head", true)];
904 expect(terminalLinkAtPositionWithRange(headCut, 0, 4)).toBeNull();
905 const tailCut = [row("https://t3.codes", false, true)];
906 expect(terminalLinkAtPositionWithRange(tailCut, 0, 8)).toBeNull();
907 const complete = [row("https://t3.codes", false), row("", false)];
908 expect(terminalLinkAtPositionWithRange(complete, 0, 8)?.text).toBe("https://t3.codes");
909 const wideFull: GhosttyRow = {
910 cells: [
911 { ...cell("π"), wide: 1 },
912 { ...cell(""), wide: 2 },
913 ...Array.from("https://t3.code", (character) => cell(character)),
914 ],
915 text: "π https://t3.code",
916 isWrapContinuation: false,
917 wrapsToNext: true,
918 };
919 expect(terminalLinkAtPositionWithRange([wideFull], 0, 8)).toBeNull();
920 const unwrittenTail: GhosttyRow = {
921 cells: [
922 ...Array.from("https://t3.codes", (character) => cell(character)),
923 cell(""),
924 cell(""),
925 ],
926 text: "https://t3.codes",
927 isWrapContinuation: false,
928 wrapsToNext: false,
929 };
930 expect(terminalLinkAtPositionWithRange([unwrittenTail], 0, 8)?.text).toBe("https://t3.codes");
931 });
932});
933β
934describe("isTerminalCopyShortcut", () => {
935 const event = (overrides: Partial<Parameters<typeof isTerminalCopyShortcut>[0]> = {}) => ({
936 altKey: false,
937 code: "KeyC",
938 ctrlKey: false,
939 key: "c",
940 metaKey: false,
941 shiftKey: false,
942 ...overrides,
943 });
944β
945 it("keeps Ctrl+C available for SIGINT on macOS", () => {
946 expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false);
947 expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
948 });
949β
950 it("uses the produced character instead of the physical key position", () => {
951 expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true);
952 expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false);
953 });
954});
955β
956describe("applyTerminalCopyEvent", () => {
957 // regression: #7678 copying a terminal selection produced a blank clipboard
958 it("leaves the writeText fallback alive when clipboardData is missing", () => {
959 // Electron menu Copy often omits clipboardData.
960 expect(applyTerminalCopyEvent("ls -la", null)).toEqual({
961 preventDefault: false,
962 claimWriteFallback: false,
963 });
964 expect(applyTerminalCopyEvent("", { setData: vi.fn() })).toEqual({
965 preventDefault: false,
966 claimWriteFallback: false,
967 });
968 });
969β
970 it("primes the current selection before a copy event with no clipboardData", () => {
971 const input = {
972 value: "stale",
973 selectionStart: 0,
974 selectionEnd: 0,
975 select() {
976 this.selectionStart = 0;
977 this.selectionEnd = this.value.length;
978 },
979 };
980 primeTerminalCopyInput(input, "git status");
981 expect(applyTerminalCopyEvent("git status", null)).toEqual({
982 preventDefault: false,
983 claimWriteFallback: false,
984 });
985 expect(input.value).toBe("git status");
986 expect(input.selectionStart).toBe(0);
987 expect(input.selectionEnd).toBe(10);
988 });
989});
990β
991describe("primeTerminalCopyInput", () => {
992 it("does not wipe an IME candidate that replaced the primed copy", () => {
993 const input = { value: "", select() {} };
994 primeTerminalCopyInput(input, "git status");
995 input.value = "γ";
996 clearPrimedTerminalCopyInput(input, "git status");
997 expect(input.value).toBe("γ");
998 });
999});
1000β
1001describe("isTerminalCompositionKey", () => {
1002 const event = (
1003 overrides: Partial<Pick<KeyboardEvent, "isComposing" | "key" | "keyCode">> = {},
1004 ) => ({
1005 isComposing: false,
1006 key: "a",
1007 keyCode: 65,
1008 ...overrides,
1009 });
1010β
1011 it("treats in-progress IME keydowns as composition so the copy primer cannot wipe them", () => {
1012 expect(isTerminalCompositionKey(event({ isComposing: true }), false)).toBe(true);
1013 expect(isTerminalCompositionKey(event(), true)).toBe(true);
1014 expect(isTerminalCompositionKey(event({ key: "Process" }), false)).toBe(true);
1015 expect(isTerminalCompositionKey(event({ keyCode: 229 }), false)).toBe(true);
1016 });
1017});
1018β
1019describe("application mouse reporting", () => {
1020 it("drops repeated motion reports until another mouse action resets the cell", () => {
1021 const first = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", "");
1022 expect(first).toEqual({ send: true, nextMotionData: "\u001b[<35;8;4M" });
1023β
1024 const duplicate = resolveTerminalMouseData("motion", "\u001b[<35;8;4M", first.nextMotionData);
1025 expect(duplicate).toEqual({ send: false, nextMotionData: "\u001b[<35;8;4M" });
1026β
1027 const press = resolveTerminalMouseData("press", "\u001b[<0;8;4M", duplicate.nextMotionData);
1028 expect(press).toEqual({ send: true, nextMotionData: "" });
1029β
1030 expect(resolveTerminalMouseData("motion", "\u001b[<35;8;4M", press.nextMotionData)).toEqual({
1031 send: true,
1032 nextMotionData: "\u001b[<35;8;4M",
1033 });
1034 });
1035});
1036β
1037describe("terminalContentOriginY", () => {
1038 it("keeps the prompt stationary while a drag crosses row boundaries", () => {
1039 for (let height = 88; height < 104; height += 1) {
1040 const rows = Math.max(1, Math.floor((height - 8) / 16));
1041 const origin = terminalContentOriginY(height, 4, rows, 16, true);
1042 expect(origin + rows * 16).toBe(height - 4);
1043 }
1044 });
1045});
1046β
1047describe("terminalWheelDeltaRows", () => {
1048 it("accumulates fractional pixel deltas across events", () => {
1049 let remainder = 0;
1050 let scrolled = 0;
1051 for (let index = 0; index < 4; index += 1) {
1052 const result = terminalWheelDeltaRows({ deltaY: 5, deltaMode: 0 }, 16, 24, remainder);
1053 remainder = result.remainder;
1054 scrolled += result.rows;
1055 }
1056 expect(scrolled).toBe(1);
1057 expect(remainder).toBeCloseTo(4 / 16);
1058 });
1059});
1060β
1061// regression: #19492 drag selection scrolled one row per tick however far past the edge
1062describe("terminalSelectionAutoscrollRows", () => {
1063 it("scrolls more rows per tick the farther the pointer is past an edge", () => {
1064 const top = 100;
1065 const bottom = 500;
1066 expect(terminalSelectionAutoscrollRows(300, top, bottom)).toBe(0);
1067 expect(terminalSelectionAutoscrollRows(99, top, bottom)).toBe(-1);
1068 expect(terminalSelectionAutoscrollRows(75, top, bottom)).toBe(-8);
1069 expect(terminalSelectionAutoscrollRows(501, top, bottom)).toBe(1);
1070 expect(terminalSelectionAutoscrollRows(525, top, bottom)).toBe(8);
1071 expect(terminalSelectionAutoscrollRows(550, top, bottom)).toBe(15);
1072 expect(terminalSelectionAutoscrollRows(2_000, top, bottom)).toBe(15);
1073 });
1074});
1075β
1076describe("advanceTerminalSelectionClickSequence", () => {
1077 it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => {
1078 const first = advanceTerminalSelectionClickSequence(null, {
1079 clientX: 20,
1080 clientY: 30,
1081 timeStamp: 1_000,
1082 });
1083 const second = advanceTerminalSelectionClickSequence(first, {
1084 clientX: 22,
1085 clientY: 29,
1086 timeStamp: 1_200,
1087 });
1088 const third = advanceTerminalSelectionClickSequence(second, {
1089 clientX: 21,
1090 clientY: 31,
1091 timeStamp: 1_400,
1092 });
1093β
1094 expect([first.count, second.count, third.count]).toEqual([1, 2, 3]);
1095 });
1096});