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", () => {...@@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => {
320 call("ghostty_wasm_free_u8_array", options, 8);320 call("ghostty_wasm_free_u8_array", options, 8);
321 });321 });
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
323 it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => {406 it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => {
324 const result = await WebAssembly.instantiate(407 const result = await WebAssembly.instantiate(
325 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,408 decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
apps/web/src/terminal/ghostty/surface.test.ts+99
...@@ -5,13 +5,17 @@ import {...@@ -5,13 +5,17 @@ import {
5 DEFAULT_TERMINAL_FONT_FAMILY,5 DEFAULT_TERMINAL_FONT_FAMILY,
6 DEFAULT_TERMINAL_FONT_SIZE,6 DEFAULT_TERMINAL_FONT_SIZE,
7 advanceTerminalSelectionClickSequence,7 advanceTerminalSelectionClickSequence,
8 applyTerminalCopyEvent,
9 clearPrimedTerminalCopyInput,
8 ghosttyMouseButton,10 ghosttyMouseButton,
9 isTerminalAltGraphText,11 isTerminalAltGraphText,
10 isTerminalCompositionCommitInput,12 isTerminalCompositionCommitInput,
13 isTerminalCompositionKey,
11 isTerminalCopyShortcut,14 isTerminalCopyShortcut,
12 isTerminalLinkPointerGesture,15 isTerminalLinkPointerGesture,
13 isTerminalPasteShortcut,16 isTerminalPasteShortcut,
14 loadTerminalFontFamily,17 loadTerminalFontFamily,
18 primeTerminalCopyInput,
15 shouldBlinkTerminalCursor,19 shouldBlinkTerminalCursor,
16 shouldReportTerminalMouse,20 shouldReportTerminalMouse,
17 shouldShowTerminalLinkHover,21 shouldShowTerminalLinkHover,
...@@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => {...@@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => {
233 });237 });
234});238});
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
236describe("isTerminalPasteShortcut", () => {313describe("isTerminalPasteShortcut", () => {
237 const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({314 const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({
238 ctrlKey: false,315 ctrlKey: false,
...@@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => {...@@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => {
283 });360 });
284});361});
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
286describe("application mouse reporting", () => {385describe("application mouse reporting", () => {
287 const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({386 const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({
288 ctrlKey: false,387 ctrlKey: false,
apps/web/src/terminal/ghostty/surface.ts+87-16
...@@ -336,6 +336,47 @@ export function isTerminalCopyShortcut(...@@ -336,6 +336,47 @@ export function isTerminalCopyShortcut(
336 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;336 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
337}337}
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
339export function isTerminalPasteShortcut(380export function isTerminalPasteShortcut(
340 event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,381 event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,
341 platform = navigator.platform,382 platform = navigator.platform,
...@@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputT...@@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputT
356 );397 );
357}398}
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
359export function isTerminalAltGraphText(408export function isTerminalAltGraphText(
360 event: Pick<KeyboardEvent, "getModifierState" | "key">,409 event: Pick<KeyboardEvent, "getModifierState" | "key">,
361): boolean {410): boolean {
...@@ -542,6 +591,7 @@ export class GhosttyTerminalSurface {...@@ -542,6 +591,7 @@ export class GhosttyTerminalSurface {
542 private pasteShortcutToken = 0;591 private pasteShortcutToken = 0;
543 private copyShortcutToken = 0;592 private copyShortcutToken = 0;
544 private clearSelectionAfterCopy = false;593 private clearSelectionAfterCopy = false;
594 private primedCopySelection = "";
545 private wheelRemainder = 0;595 private wheelRemainder = 0;
546 private dprMedia: MediaQueryList | null = null;596 private dprMedia: MediaQueryList | null = null;
547 // Read live on every blink decision, and watched so that dropping the597 // Read live on every blink decision, and watched so that dropping the
...@@ -866,6 +916,7 @@ export class GhosttyTerminalSurface {...@@ -866,6 +916,7 @@ export class GhosttyTerminalSurface {
866 }916 }
867917
868 clearSelection(): void {918 clearSelection(): void {
919 this.clearPrimedCopy();
869 this.core.clearSelection();920 this.core.clearSelection();
870 this.selectionEnd = null;921 this.selectionEnd = null;
871 this.selectionAnchorScreen = null;922 this.selectionAnchorScreen = null;
...@@ -941,6 +992,8 @@ export class GhosttyTerminalSurface {...@@ -941,6 +992,8 @@ export class GhosttyTerminalSurface {
941 // clipboard write against it the same way paste races its read. The992 // clipboard write against it the same way paste races its read. The
942 // Shift variant has no native event (Chrome binds Ctrl+Shift+C to993 // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
943 // inspect), so synthesize one with execCommand("copy").994 // inspect), so synthesize one with execCommand("copy").
995 const selection = this.getSelection();
996 this.primeCopy(selection);
944 if (event.shiftKey) {997 if (event.shiftKey) {
945 event.preventDefault();998 event.preventDefault();
946 document.execCommand("copy");999 document.execCommand("copy");
...@@ -955,12 +1008,10 @@ export class GhosttyTerminalSurface {...@@ -955,12 +1008,10 @@ export class GhosttyTerminalSurface {
955 if (typeof clipboard?.writeText === "function") {1008 if (typeof clipboard?.writeText === "function") {
956 // Defer the write past the default action: the native copy event1009 // Defer the write past the default action: the native copy event
957 // (dispatched synchronously with the default action) claims the1010 // (dispatched synchronously with the default action) claims the
958 // token first when it fires, and the write covers browsers whose1011 // token first when it actually writes, and the write covers browsers
959 // shortcut produces no copy event. Skipping a write the native1012 // whose shortcut produces no copy event. The primed textarea is what
960 // event already handled stops a stale resolution from clobbering a1013 // Electron's edit-menu Copy reads if it runs after this handler.
961 // clipboard the user filled after this copy.
962 const token = ++this.copyShortcutToken;1014 const token = ++this.copyShortcutToken;
963 const selection = this.getSelection();
964 void Promise.resolve().then(() => {1015 void Promise.resolve().then(() => {
965 if (this.disposed || this.copyShortcutToken !== token) return;1016 if (this.disposed || this.copyShortcutToken !== token) return;
966 void clipboard.writeText(selection).then(1017 void clipboard.writeText(selection).then(
...@@ -1013,10 +1064,12 @@ export class GhosttyTerminalSurface {...@@ -1013,10 +1064,12 @@ export class GhosttyTerminalSurface {
1013 return;1064 return;
1014 }1065 }
1015 // keyCode 229 is Safari's only signal that this keydown opens an IME1066 // keyCode 229 is Safari's only signal that this keydown opens an IME
1016 // composition; encoding it would double the committed text.1067 // composition; encoding it would double the committed text. Do not blank
1017 if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {1068 // the textarea first: onInput leaves the in-progress candidate there.
1069 if (isTerminalCompositionKey(event, this.composing)) {
1018 return;1070 return;
1019 }1071 }
1072 this.clearPrimedCopy();
1020 const data = this.core.encodeKey(event);1073 const data = this.core.encodeKey(event);
1021 if (data.length === 0) return;1074 if (data.length === 0) return;
1022 this.suppressedKeyCodes.delete(event.code);1075 this.suppressedKeyCodes.delete(event.code);
...@@ -1028,7 +1081,7 @@ export class GhosttyTerminalSurface {...@@ -1028,7 +1081,7 @@ export class GhosttyTerminalSurface {
1028 private readonly onKeyUp = (event: KeyboardEvent) => {1081 private readonly onKeyUp = (event: KeyboardEvent) => {
1029 this.updateLinkModifier(event);1082 this.updateLinkModifier(event);
1030 if (this.suppressedKeyCodes.delete(event.code)) return;1083 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)) {
1032 return;1085 return;
1033 }1086 }
1034 // Ghostty's encoder only emits release codes when the terminal enabled the1087 // Ghostty's encoder only emits release codes when the terminal enabled the
...@@ -1072,15 +1125,32 @@ export class GhosttyTerminalSurface {...@@ -1072,15 +1125,32 @@ export class GhosttyTerminalSurface {
1072 this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);1125 this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);
1073 }1126 }
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
1075 private readonly onCopyEvent = (event: ClipboardEvent) => {1138 private readonly onCopyEvent = (event: ClipboardEvent) => {
1076 if (!this.hasSelection()) return;1139 const selection = this.hasSelection() ? this.getSelection() : this.input.value;
1077 event.preventDefault();1140 // Menu-role Copy never hits the keydown primer. The native action reads
1078 event.clipboardData?.setData("text/plain", this.getSelection());1141 // this.input, so park the current selection first — including when
1079 // The native event beat any deferred write; drop the in-flight fallback.1142 // clipboardData is missing and we must not preventDefault.
1080 this.copyShortcutToken += 1;1143 this.primeCopy(selection);
1081 if (this.clearSelectionAfterCopy) {1144 const result = applyTerminalCopyEvent(selection, event.clipboardData);
1082 this.clearSelectionAfterCopy = false;1145 if (result.preventDefault) event.preventDefault();
1083 this.clearSelection();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 }
1084 }1154 }
1085 };1155 };
10861156
...@@ -1098,6 +1168,7 @@ export class GhosttyTerminalSurface {...@@ -1098,6 +1168,7 @@ export class GhosttyTerminalSurface {
1098 };1168 };
10991169
1100 private readonly onCompositionStart = () => {1170 private readonly onCompositionStart = () => {
1171 this.clearPrimedCopy();
1101 this.clearCompositionInputSuppression();1172 this.clearCompositionInputSuppression();
1102 this.composing = true;1173 this.composing = true;
1103 };1174 };