authorgravatar for sethwebster@gmail.comSeth Webster <sethwebster@gmail.com> 2026-08-20 15:56:58-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-20 19:56:58+00:00
log21e80a06364898b8ecffad8aeddcb5634170260b
tree728d398e51d05fae13452e36c8862f6be912ee14
parent8824f8f24f8b81fd6aed6f228a57a1d4013d01d9
signature Signed by PGP key B5690EEEBB952194

fix(web): copy terminal selection instead of a blank clipboard (#7678)


3 files changed, 269 insertions(+), 16 deletions(-)

apps/web/src/terminal/ghostty/runtimeAbi.test.ts+83
......@@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => {
320320 call("ghostty_wasm_free_u8_array", options, 8);
321321 });
322322
323 it("formats a cell-drag selection installed from screen grid refs", async () => {
324 const result = await WebAssembly.instantiate(
325 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
326 { env: { log: () => {} } },
327 );
328 const instance = result instanceof WebAssembly.Instance ? result : result.instance;
329 const memory = instance.exports.memory as WebAssembly.Memory;
330 const call = (name: string, ...args: number[]) =>
331 (instance.exports[name] as WasmFunction)(...args);
332 const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
333 const free = (pointer: number, size: number) =>
334 call("ghostty_wasm_free_u8_array", pointer, size);
335 const options = alloc(8);
336 new DataView(memory.buffer, options, 8).setUint16(0, 80, true);
337 new DataView(memory.buffer, options, 8).setUint16(2, 24, true);
338 const terminalSlot = call("ghostty_wasm_alloc_opaque");
339 expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
340 const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
341 const input = new TextEncoder().encode("hello\r\nworld");
342 const inputPointer = alloc(input.length);
343 new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
344 call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);
345
346 const gridRefAt = (x: number, y: number) => {
347 const point = alloc(24);
348 const pointView = new DataView(memory.buffer, point, 24);
349 pointView.setUint32(0, 2, true);
350 pointView.setUint16(8, x, true);
351 pointView.setUint32(12, y, true);
352 const ref = alloc(12);
353 new DataView(memory.buffer, ref, 12).setUint32(0, 12, true);
354 expect(call("ghostty_terminal_grid_ref", terminal, point, ref)).toBe(0);
355 free(point, 24);
356 return ref;
357 };
358 const start = gridRefAt(0, 0);
359 const end = gridRefAt(4, 1);
360 const selection = alloc(32);
361 const selectionBytes = new Uint8Array(memory.buffer, selection, 32);
362 selectionBytes.fill(0);
363 new DataView(memory.buffer, selection, 32).setUint32(0, 32, true);
364 selectionBytes.set(new Uint8Array(memory.buffer, start, 12), 4);
365 selectionBytes.set(new Uint8Array(memory.buffer, end, 12), 16);
366 expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0);
367
368 const formatOptions = alloc(16);
369 new Uint8Array(memory.buffer, formatOptions, 16).fill(0);
370 const formatView = new DataView(memory.buffer, formatOptions, 16);
371 formatView.setUint32(0, 16, true);
372 formatView.setUint8(8, 1);
373 formatView.setUint8(9, 1);
374 const written = call("ghostty_wasm_alloc_usize");
375 expect(
376 call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written),
377 ).toBe(-3);
378 const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
379 const output = alloc(outputSize);
380 expect(
381 call(
382 "ghostty_terminal_selection_format_buf",
383 terminal,
384 formatOptions,
385 output,
386 outputSize,
387 written,
388 ),
389 ).toBe(0);
390 expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe(
391 "hello\nworld",
392 );
393
394 free(output, outputSize);
395 call("ghostty_wasm_free_usize", written);
396 free(formatOptions, 16);
397 free(selection, 32);
398 free(end, 12);
399 free(start, 12);
400 free(inputPointer, input.length);
401 call("ghostty_terminal_free", terminal);
402 call("ghostty_wasm_free_opaque", terminalSlot);
403 free(options, 8);
404 });
405
323406 it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => {
324407 const result = await WebAssembly.instantiate(
325408 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
apps/web/src/terminal/ghostty/surface.test.ts+99
......@@ -5,13 +5,17 @@ import {
55 DEFAULT_TERMINAL_FONT_FAMILY,
66 DEFAULT_TERMINAL_FONT_SIZE,
77 advanceTerminalSelectionClickSequence,
8 applyTerminalCopyEvent,
9 clearPrimedTerminalCopyInput,
810 ghosttyMouseButton,
911 isTerminalAltGraphText,
1012 isTerminalCompositionCommitInput,
13 isTerminalCompositionKey,
1114 isTerminalCopyShortcut,
1215 isTerminalLinkPointerGesture,
1316 isTerminalPasteShortcut,
1417 loadTerminalFontFamily,
18 primeTerminalCopyInput,
1519 shouldBlinkTerminalCursor,
1620 shouldReportTerminalMouse,
1721 shouldShowTerminalLinkHover,
......@@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => {
233237 });
234238});
235239
240describe("applyTerminalCopyEvent", () => {
241 it("writes the selection and claims the fallback when clipboardData is present", () => {
242 const setData = vi.fn();
243 expect(applyTerminalCopyEvent("ls -la", { setData })).toEqual({
244 preventDefault: true,
245 claimWriteFallback: true,
246 });
247 expect(setData).toHaveBeenCalledWith("text/plain", "ls -la");
248 });
249
250 it("leaves the writeText fallback alive when clipboardData is missing", () => {
251 // Electron's edit-menu Copy often delivers a copy event with no
252 // clipboardData. Claiming that event used to skip writeText and copy the
253 // empty IME textarea, which is the blank clipboard users paste.
254 expect(applyTerminalCopyEvent("ls -la", null)).toEqual({
255 preventDefault: false,
256 claimWriteFallback: false,
257 });
258 expect(applyTerminalCopyEvent("", { setData: vi.fn() })).toEqual({
259 preventDefault: false,
260 claimWriteFallback: false,
261 });
262 });
263
264 it("primes the current selection before a copy event with no clipboardData", () => {
265 const input = {
266 value: "stale",
267 selectionStart: 0,
268 selectionEnd: 0,
269 select() {
270 this.selectionStart = 0;
271 this.selectionEnd = this.value.length;
272 },
273 };
274 primeTerminalCopyInput(input, "git status");
275 expect(applyTerminalCopyEvent("git status", null)).toEqual({
276 preventDefault: false,
277 claimWriteFallback: false,
278 });
279 expect(input.value).toBe("git status");
280 expect(input.selectionStart).toBe(0);
281 expect(input.selectionEnd).toBe(10);
282 });
283});
284
285describe("primeTerminalCopyInput", () => {
286 it("selects the Ghostty selection in the hidden textarea so native copy has text", () => {
287 const input = {
288 value: "",
289 selectionStart: 0,
290 selectionEnd: 0,
291 select() {
292 this.selectionStart = 0;
293 this.selectionEnd = this.value.length;
294 },
295 };
296 primeTerminalCopyInput(input, "git status");
297 expect(input.value).toBe("git status");
298 expect(input.selectionStart).toBe(0);
299 expect(input.selectionEnd).toBe(10);
300 clearPrimedTerminalCopyInput(input, "git status");
301 expect(input.value).toBe("");
302 });
303
304 it("does not wipe an IME candidate that replaced the primed copy", () => {
305 const input = { value: "", select() {} };
306 primeTerminalCopyInput(input, "git status");
307 input.value = "あ";
308 clearPrimedTerminalCopyInput(input, "git status");
309 expect(input.value).toBe("あ");
310 });
311});
312
236313describe("isTerminalPasteShortcut", () => {
237314 const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({
238315 ctrlKey: false,
......@@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => {
283360 });
284361});
285362
363describe("isTerminalCompositionKey", () => {
364 const event = (
365 overrides: Partial<Pick<KeyboardEvent, "isComposing" | "key" | "keyCode">> = {},
366 ) => ({
367 isComposing: false,
368 key: "a",
369 keyCode: 65,
370 ...overrides,
371 });
372
373 it("treats in-progress IME keydowns as composition so the copy primer cannot wipe them", () => {
374 expect(isTerminalCompositionKey(event({ isComposing: true }), false)).toBe(true);
375 expect(isTerminalCompositionKey(event(), true)).toBe(true);
376 expect(isTerminalCompositionKey(event({ key: "Process" }), false)).toBe(true);
377 expect(isTerminalCompositionKey(event({ keyCode: 229 }), false)).toBe(true);
378 });
379
380 it("lets ordinary keydowns clear a primed copy", () => {
381 expect(isTerminalCompositionKey(event(), false)).toBe(false);
382 });
383});
384
286385describe("application mouse reporting", () => {
287386 const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({
288387 ctrlKey: false,
apps/web/src/terminal/ghostty/surface.ts+87-16
......@@ -336,6 +336,47 @@ export function isTerminalCopyShortcut(
336336 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
337337}
338338
339/**
340 * Canvas terminals have no DOM selection. Native copy and Electron's Edit
341 * menu `role: "copy"` both read the focused textarea, so an empty IME field
342 * writes blankness to the clipboard. Park the Ghostty selection there first.
343 */
344export function primeTerminalCopyInput(
345 input: Pick<HTMLTextAreaElement, "value" | "select">,
346 selection: string,
347): void {
348 input.value = selection;
349 if (selection.length === 0) return;
350 input.select();
351}
352
353export function clearPrimedTerminalCopyInput(
354 input: Pick<HTMLTextAreaElement, "value">,
355 primedSelection: string,
356): void {
357 // Only blank the copy we parked. The same textarea holds the IME candidate;
358 // wiping whatever is there would cancel CJK composition.
359 if (primedSelection.length === 0 || input.value !== primedSelection) return;
360 input.value = "";
361}
362
363/**
364 * Only a copy event that actually received the selection may cancel the
365 * clipboard.writeText fallback. Claiming without clipboardData (Electron's
366 * menu Copy) used to preventDefault an empty write and skip the fallback,
367 * which is how Cmd+C copied blankness.
368 */
369export function applyTerminalCopyEvent(
370 selection: string,
371 clipboardData: { setData: (type: string, data: string) => void } | null | undefined,
372): { preventDefault: boolean; claimWriteFallback: boolean } {
373 if (selection.length === 0 || !clipboardData) {
374 return { preventDefault: false, claimWriteFallback: false };
375 }
376 clipboardData.setData("text/plain", selection);
377 return { preventDefault: true, claimWriteFallback: true };
378}
379
339380export function isTerminalPasteShortcut(
340381 event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,
341382 platform = navigator.platform,
......@@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputT
356397 );
357398}
358399
400/** IME keydowns must not touch the hidden textarea; it holds the candidate. */
401export function isTerminalCompositionKey(
402 event: Pick<KeyboardEvent, "isComposing" | "key" | "keyCode">,
403 composing: boolean,
404): boolean {
405 return event.isComposing || composing || event.key === "Process" || event.keyCode === 229;
406}
407
359408export function isTerminalAltGraphText(
360409 event: Pick<KeyboardEvent, "getModifierState" | "key">,
361410): boolean {
......@@ -542,6 +591,7 @@ export class GhosttyTerminalSurface {
542591 private pasteShortcutToken = 0;
543592 private copyShortcutToken = 0;
544593 private clearSelectionAfterCopy = false;
594 private primedCopySelection = "";
545595 private wheelRemainder = 0;
546596 private dprMedia: MediaQueryList | null = null;
547597 // Read live on every blink decision, and watched so that dropping the
......@@ -866,6 +916,7 @@ export class GhosttyTerminalSurface {
866916 }
867917
868918 clearSelection(): void {
919 this.clearPrimedCopy();
869920 this.core.clearSelection();
870921 this.selectionEnd = null;
871922 this.selectionAnchorScreen = null;
......@@ -941,6 +992,8 @@ export class GhosttyTerminalSurface {
941992 // clipboard write against it the same way paste races its read. The
942993 // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
943994 // inspect), so synthesize one with execCommand("copy").
995 const selection = this.getSelection();
996 this.primeCopy(selection);
944997 if (event.shiftKey) {
945998 event.preventDefault();
946999 document.execCommand("copy");
......@@ -955,12 +1008,10 @@ export class GhosttyTerminalSurface {
9551008 if (typeof clipboard?.writeText === "function") {
9561009 // Defer the write past the default action: the native copy event
9571010 // (dispatched synchronously with the default action) claims the
958 // token first when it fires, and the write covers browsers whose
959 // shortcut produces no copy event. Skipping a write the native
960 // event already handled stops a stale resolution from clobbering a
961 // clipboard the user filled after this copy.
1011 // token first when it actually writes, and the write covers browsers
1012 // whose shortcut produces no copy event. The primed textarea is what
1013 // Electron's edit-menu Copy reads if it runs after this handler.
9621014 const token = ++this.copyShortcutToken;
963 const selection = this.getSelection();
9641015 void Promise.resolve().then(() => {
9651016 if (this.disposed || this.copyShortcutToken !== token) return;
9661017 void clipboard.writeText(selection).then(
......@@ -1013,10 +1064,12 @@ export class GhosttyTerminalSurface {
10131064 return;
10141065 }
10151066 // keyCode 229 is Safari's only signal that this keydown opens an IME
1016 // composition; encoding it would double the committed text.
1017 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
1067 // composition; encoding it would double the committed text. Do not blank
1068 // the textarea first: onInput leaves the in-progress candidate there.
1069 if (isTerminalCompositionKey(event, this.composing)) {
10181070 return;
10191071 }
1072 this.clearPrimedCopy();
10201073 const data = this.core.encodeKey(event);
10211074 if (data.length === 0) return;
10221075 this.suppressedKeyCodes.delete(event.code);
......@@ -1028,7 +1081,7 @@ export class GhosttyTerminalSurface {
10281081 private readonly onKeyUp = (event: KeyboardEvent) => {
10291082 this.updateLinkModifier(event);
10301083 if (this.suppressedKeyCodes.delete(event.code)) return;
1031 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
1084 if (isTerminalCompositionKey(event, this.composing)) {
10321085 return;
10331086 }
10341087 // Ghostty's encoder only emits release codes when the terminal enabled the
......@@ -1072,15 +1125,32 @@ export class GhosttyTerminalSurface {
10721125 this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);
10731126 }
10741127
1128 private primeCopy(selection: string): void {
1129 this.primedCopySelection = selection;
1130 primeTerminalCopyInput(this.input, selection);
1131 }
1132
1133 private clearPrimedCopy(): void {
1134 clearPrimedTerminalCopyInput(this.input, this.primedCopySelection);
1135 this.primedCopySelection = "";
1136 }
1137
10751138 private readonly onCopyEvent = (event: ClipboardEvent) => {
1076 if (!this.hasSelection()) return;
1077 event.preventDefault();
1078 event.clipboardData?.setData("text/plain", this.getSelection());
1079 // The native event beat any deferred write; drop the in-flight fallback.
1080 this.copyShortcutToken += 1;
1081 if (this.clearSelectionAfterCopy) {
1082 this.clearSelectionAfterCopy = false;
1083 this.clearSelection();
1139 const selection = this.hasSelection() ? this.getSelection() : this.input.value;
1140 // Menu-role Copy never hits the keydown primer. The native action reads
1141 // this.input, so park the current selection first — including when
1142 // clipboardData is missing and we must not preventDefault.
1143 this.primeCopy(selection);
1144 const result = applyTerminalCopyEvent(selection, event.clipboardData);
1145 if (result.preventDefault) event.preventDefault();
1146 if (result.claimWriteFallback) {
1147 // The native event actually wrote the selection; drop the in-flight
1148 // writeText so a late resolution cannot clobber a later user copy.
1149 this.copyShortcutToken += 1;
1150 if (this.clearSelectionAfterCopy) {
1151 this.clearSelectionAfterCopy = false;
1152 this.clearSelection();
1153 }
10841154 }
10851155 };
10861156
......@@ -1098,6 +1168,7 @@ export class GhosttyTerminalSurface {
10981168 };
10991169
11001170 private readonly onCompositionStart = () => {
1171 this.clearPrimedCopy();
11011172 this.clearCompositionInputSuppression();
11021173 this.composing = true;
11031174 };