authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-14 23:22:57+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 23:22:57+05:30
log1add47b322ab1dfb5010bb363613650176b88088
treebb013f1d4340b2b08ee466efcca69c1aed24236f
parent80991402dcdc488838fb4d8b21171bd38d9ab0aa
signature Signed by PGP key B5690EEEBB952194

fix(web): add copying terminal selection with ctrl+c in the web app (#5638)


9 files changed, 159 insertions(+), 21 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+6-12
...@@ -440,7 +440,6 @@ export function TerminalViewport({...@@ -440,7 +440,6 @@ export function TerminalViewport({
440 onData: (data) => handleData(data),440 onData: (data) => handleData(data),
441 onResize: (cols, rows) => void resizeTerminal(cols, rows),441 onResize: (cols, rows) => void resizeTerminal(cols, rows),
442 onSelectionChange: () => handleSelectionChange(),442 onSelectionChange: () => handleSelectionChange(),
443 onCopy: (text) => handleCopy(text),
444 beforeKey: (event) => handleBeforeKey(event),443 beforeKey: (event) => handleBeforeKey(event),
445 onLinkActivate: (text, event) => handleLinkActivate(text, event),444 onLinkActivate: (text, event) => handleLinkActivate(text, event),
446 };445 };
...@@ -668,17 +667,6 @@ export function TerminalViewport({...@@ -668,17 +667,6 @@ export function TerminalViewport({
668 })();667 })();
669 }668 }
670669
671 function handleCopy(text: string): void {
672 void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => {
673 const activeTerminal = terminalRef.current;
674 if (!activeTerminal) return;
675 writeSystemMessage(
676 activeTerminal,
677 error instanceof Error ? error.message : "Unable to copy terminal selection",
678 );
679 });
680 }
681
682 function handleData(data: string): void {670 function handleData(data: string): void {
683 void (async () => {671 void (async () => {
684 const result = await writeTerminal(data);672 const result = await writeTerminal(data);
...@@ -696,6 +684,12 @@ export function TerminalViewport({...@@ -696,6 +684,12 @@ export function TerminalViewport({
696 return;684 return;
697 }685 }
698 clearSelectionAction();686 clearSelectionAction();
687 // A copy shortcut that clears the selection (Ctrl+C) must also close
688 // the context menu that appears with the selection, but a clear that
689 // never opened a menu must not dismiss an unrelated one.
690 if (selectionActionMenuOpenRef.current) {
691 void localApi?.contextMenu.close();
692 }
699 }693 }
700694
701 const handleMouseUp = (event: MouseEvent) => {695 const handleMouseUp = (event: MouseEvent) => {
apps/web/src/components/settings/SettingsFontPreviews.tsx-1
...@@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu...@@ -238,7 +238,6 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu
238 onData: echo,238 onData: echo,
239 onResize: noop,239 onResize: noop,
240 onSelectionChange: noop,240 onSelectionChange: noop,
241 onCopy: (text) => void navigator.clipboard?.writeText(text).catch(noop),
242 // Tab keeps walking the settings page instead of feeding the echo loop.241 // Tab keeps walking the settings page instead of feeding the echo loop.
243 beforeKey: (event) => event.key !== "Tab",242 beforeKey: (event) => event.key !== "Tab",
244 onLinkActivate: noop,243 onLinkActivate: noop,
apps/web/src/contextMenuFallback.test.ts+35-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22
3import { showContextMenuFallback } from "./contextMenuFallback";3import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
44
5type FakeListener = (event: FakeDomEvent) => void;5type FakeListener = (event: FakeDomEvent) => void;
66
...@@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => {...@@ -236,3 +236,37 @@ describe("showContextMenuFallback", () => {
236 await expect(selectionPromise).resolves.toBe("rename:project-b");236 await expect(selectionPromise).resolves.toBe("rename:project-b");
237 });237 });
238});238});
239
240describe("dismissContextMenu", () => {
241 it("resolves an open menu with null", async () => {
242 const selectionPromise = showContextMenuFallback([
243 { id: "rename", label: "Rename" },
244 { id: "delete", label: "Delete" },
245 ]);
246 expect(findButton("Rename")).toBeTruthy();
247
248 dismissContextMenu();
249
250 await expect(selectionPromise).resolves.toBeNull();
251 expect(findButton("Rename")).toBeUndefined();
252 });
253
254 it("is a no-op when no menu is open", async () => {
255 dismissContextMenu();
256 expect(findButton("Rename")).toBeUndefined();
257 });
258
259 it("dismisses the prior menu when a new one opens", async () => {
260 const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]);
261 expect(findButton("First")).toBeTruthy();
262
263 const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]);
264
265 await expect(firstPromise).resolves.toBeNull();
266 expect(findButton("First")).toBeUndefined();
267 expect(findButton("Second")).toBeTruthy();
268
269 dismissContextMenu();
270 await expect(secondPromise).resolves.toBeNull();
271 });
272});
apps/web/src/contextMenuFallback.ts+27
...@@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H...@@ -101,6 +101,21 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H
101 return false;101 return false;
102}102}
103103
104// Only one fallback menu exists at a time in the renderer; the active one is
105// tracked so a state change (for example a terminal selection clearing) can
106// dismiss it with the same result as an outside click or Escape.
107let activeContextMenuDismiss: (() => void) | null = null;
108
109/**
110 * Closes the currently open fallback context menu, resolving its show() with
111 * null (the same result as dismissing by outside click or Escape). No-op when
112 * no fallback menu is open.
113 */
114export function dismissContextMenu(): void {
115 activeContextMenuDismiss?.();
116 activeContextMenuDismiss = null;
117}
118
104/**119/**
105 * Imperative DOM-based context menu for non-Electron environments.120 * Imperative DOM-based context menu for non-Electron environments.
106 * Supports nested submenus and resolves with the clicked leaf item id.121 * Supports nested submenus and resolves with the clicked leaf item id.
...@@ -114,11 +129,16 @@ export function showContextMenuFallback<T extends string>(...@@ -114,11 +129,16 @@ export function showContextMenuFallback<T extends string>(
114 let isDisposed = false;129 let isDisposed = false;
115 let canDismissFromPointer = false;130 let canDismissFromPointer = false;
116131
132 const dismiss = () => cleanup(null);
133
117 const cleanup = (result: T | null) => {134 const cleanup = (result: T | null) => {
118 if (isDisposed) {135 if (isDisposed) {
119 return;136 return;
120 }137 }
121 isDisposed = true;138 isDisposed = true;
139 if (activeContextMenuDismiss === dismiss) {
140 activeContextMenuDismiss = null;
141 }
122 document.removeEventListener("keydown", onKeyDown);142 document.removeEventListener("keydown", onKeyDown);
123 document.removeEventListener("pointerdown", onPointerDown, true);143 document.removeEventListener("pointerdown", onPointerDown, true);
124 document.removeEventListener("contextmenu", onContextMenu, true);144 document.removeEventListener("contextmenu", onContextMenu, true);
...@@ -299,6 +319,13 @@ export function showContextMenuFallback<T extends string>(...@@ -299,6 +319,13 @@ export function showContextMenuFallback<T extends string>(
299 document.addEventListener("pointerdown", onPointerDown, true);319 document.addEventListener("pointerdown", onPointerDown, true);
300 document.addEventListener("contextmenu", onContextMenu, true);320 document.addEventListener("contextmenu", onContextMenu, true);
301 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);321 openMenu(items, position?.x ?? 0, position?.y ?? 0, 0);
322 // Only one fallback menu can be open at a time: a new show must dismiss
323 // any prior one, or its DOM and listeners leak and close() can only ever
324 // reach the newest menu.
325 if (activeContextMenuDismiss) {
326 activeContextMenuDismiss();
327 }
328 activeContextMenuDismiss = dismiss;
302329
303 requestAnimationFrame(() => {330 requestAnimationFrame(() => {
304 canDismissFromPointer = true;331 canDismissFromPointer = true;
apps/web/src/localApi.test.ts+10
...@@ -13,12 +13,14 @@ const showContextMenuFallbackMock =...@@ -13,12 +13,14 @@ const showContextMenuFallbackMock =
13 position?: { x: number; y: number },13 position?: { x: number; y: number },
14 ) => Promise<T | null>14 ) => Promise<T | null>
15 >();15 >();
16const dismissContextMenuMock = vi.fn<() => void>();
1617
17const requestConfirmDialogMock =18const requestConfirmDialogMock =
18 vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>();19 vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>();
1920
20vi.mock("./contextMenuFallback", () => ({21vi.mock("./contextMenuFallback", () => ({
21 showContextMenuFallback: showContextMenuFallbackMock,22 showContextMenuFallback: showContextMenuFallbackMock,
23 dismissContextMenu: dismissContextMenuMock,
22}));24}));
2325
24vi.mock("./confirmDialog", () => ({26vi.mock("./confirmDialog", () => ({
...@@ -85,6 +87,14 @@ describe("LocalApi", () => {...@@ -85,6 +87,14 @@ describe("LocalApi", () => {
85 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });87 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
86 });88 });
8789
90 it("dismisses an open browser context menu without a desktop bridge", async () => {
91 const { createLocalApi } = await import("./localApi");
92
93 await createLocalApi().contextMenu.close();
94
95 expect(dismissContextMenuMock).toHaveBeenCalledOnce();
96 });
97
88 it("uses the themed confirmation host when it is available", async () => {98 it("uses the themed confirmation host when it is available", async () => {
89 requestConfirmDialogMock.mockResolvedValue(true);99 requestConfirmDialogMock.mockResolvedValue(true);
90 const { createLocalApi } = await import("./localApi");100 const { createLocalApi } = await import("./localApi");
apps/web/src/localApi.ts+9-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts";1import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts";
22
3import { requestConfirmDialog } from "./confirmDialog";3import { requestConfirmDialog } from "./confirmDialog";
4import { showContextMenuFallback } from "./contextMenuFallback";4import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
5import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";5import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
77
...@@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi {...@@ -41,6 +41,14 @@ function createBrowserLocalApi(): LocalApi {
41 }41 }
42 return showContextMenuFallback(items, position);42 return showContextMenuFallback(items, position);
43 },43 },
44 // A native desktop menu blocks keyboard input and closes on outside
45 // interaction, so nothing to do there; the DOM fallback needs an explicit
46 // dismiss when the state behind it goes away.
47 close: async () => {
48 if (!window.desktopBridge) {
49 dismissContextMenu();
50 }
51 },
44 },52 },
45 persistence: {53 persistence: {
46 getClientSettings: async () => {54 getClientSettings: async () => {
apps/web/src/terminal/ghostty/surface.test.ts+3-2
...@@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => {...@@ -219,11 +219,12 @@ describe("isTerminalCopyShortcut", () => {
219 expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);219 expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
220 });220 });
221221
222 it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => {222 it("copies with Ctrl+C and Ctrl+Shift+C elsewhere", () => {
223 expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false);223 expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(true);
224 expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(224 expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(
225 true,225 true,
226 );226 );
227 expect(isTerminalCopyShortcut(event({}), "Linux x86_64")).toBe(false);
227 });228 });
228229
229 it("uses the produced character instead of the physical key position", () => {230 it("uses the produced character instead of the physical key position", () => {
apps/web/src/terminal/ghostty/surface.ts+68-4
...@@ -333,7 +333,7 @@ export function isTerminalCopyShortcut(...@@ -333,7 +333,7 @@ export function isTerminalCopyShortcut(
333 platform = navigator.platform,333 platform = navigator.platform,
334) {334) {
335 if (event.key.toLowerCase() !== "c") return false;335 if (event.key.toLowerCase() !== "c") return false;
336 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;336 return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
337}337}
338338
339export function isTerminalPasteShortcut(339export function isTerminalPasteShortcut(
...@@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions {...@@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions {
463 readonly onData: (data: string) => void;463 readonly onData: (data: string) => void;
464 readonly onResize: (cols: number, rows: number) => void;464 readonly onResize: (cols: number, rows: number) => void;
465 readonly onSelectionChange: () => void;465 readonly onSelectionChange: () => void;
466 readonly onCopy: (text: string) => void;
467 readonly beforeKey: (event: KeyboardEvent) => boolean;466 readonly beforeKey: (event: KeyboardEvent) => boolean;
468 readonly onLinkActivate: (text: string, event: MouseEvent) => void;467 readonly onLinkActivate: (text: string, event: MouseEvent) => void;
469}468}
...@@ -531,6 +530,8 @@ export class GhosttyTerminalSurface {...@@ -531,6 +530,8 @@ export class GhosttyTerminalSurface {
531 private theme: GhosttyTheme;530 private theme: GhosttyTheme;
532 private readonly suppressedKeyCodes = new Set<string>();531 private readonly suppressedKeyCodes = new Set<string>();
533 private pasteShortcutToken = 0;532 private pasteShortcutToken = 0;
533 private copyShortcutToken = 0;
534 private clearSelectionAfterCopy = false;
534 private wheelRemainder = 0;535 private wheelRemainder = 0;
535 private dprMedia: MediaQueryList | null = null;536 private dprMedia: MediaQueryList | null = null;
536 // Read live on every blink decision, and watched so that dropping the537 // Read live on every blink decision, and watched so that dropping the
...@@ -901,9 +902,58 @@ export class GhosttyTerminalSurface {...@@ -901,9 +902,58 @@ export class GhosttyTerminalSurface {
901 return;902 return;
902 }903 }
903 if (isTerminalCopyShortcut(event) && this.hasSelection()) {904 if (isTerminalCopyShortcut(event) && this.hasSelection()) {
904 event.preventDefault();905 // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
906 // onCopyEvent; not preventing the default keeps that path alive. WebKit
907 // omits the keyboard copy event without a DOM selection, so race the
908 // clipboard write against it the same way paste races its read. The
909 // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
910 // inspect), so synthesize one with execCommand("copy").
911 if (event.shiftKey) {
912 event.preventDefault();
913 document.execCommand("copy");
914 } else {
915 // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once
916 // it copies so the next Ctrl+C reaches the shell. The Shift chord and
917 // Cmd+C are copy-only, so they keep the selection; resetting the flag
918 // up front also drops any clear owed by an earlier gesture that never
919 // completed.
920 this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform);
921 const clipboard = navigator.clipboard;
922 if (typeof clipboard?.writeText === "function") {
923 // Defer the write past the default action: the native copy event
924 // (dispatched synchronously with the default action) claims the
925 // token first when it fires, and the write covers browsers whose
926 // shortcut produces no copy event. Skipping a write the native
927 // event already handled stops a stale resolution from clobbering a
928 // clipboard the user filled after this copy.
929 const token = ++this.copyShortcutToken;
930 const selection = this.getSelection();
931 void Promise.resolve().then(() => {
932 if (this.disposed || this.copyShortcutToken !== token) return;
933 void clipboard.writeText(selection).then(
934 () => {
935 // The write may have been superseded while in flight; only
936 // touch the selection if this gesture still owns the token.
937 if (this.disposed || this.copyShortcutToken !== token) return;
938 if (this.clearSelectionAfterCopy) {
939 this.clearSelectionAfterCopy = false;
940 this.clearSelection();
941 }
942 },
943 () => {
944 // The write failed and the native event has already had its
945 // chance, so nothing copied and no clear is owed by this
946 // gesture; a newer one may have just set the flag, so only
947 // drop it if this gesture still owns the token.
948 if (this.copyShortcutToken === token) {
949 this.clearSelectionAfterCopy = false;
950 }
951 },
952 );
953 });
954 }
955 }
905 this.suppressedKeyCodes.add(event.code);956 this.suppressedKeyCodes.add(event.code);
906 this.options.onCopy(this.getSelection());
907 return;957 return;
908 }958 }
909 if (isTerminalPasteShortcut(event)) {959 if (isTerminalPasteShortcut(event)) {
...@@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface {...@@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface {
989 this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);1039 this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);
990 }1040 }
9911041
1042 private readonly onCopyEvent = (event: ClipboardEvent) => {
1043 if (!this.hasSelection()) return;
1044 event.preventDefault();
1045 event.clipboardData?.setData("text/plain", this.getSelection());
1046 // The native event beat any deferred write; drop the in-flight fallback.
1047 this.copyShortcutToken += 1;
1048 if (this.clearSelectionAfterCopy) {
1049 this.clearSelectionAfterCopy = false;
1050 this.clearSelection();
1051 }
1052 };
1053
992 private readonly onPaste = (event: ClipboardEvent) => {1054 private readonly onPaste = (event: ClipboardEvent) => {
993 // Always suppress the browser's default insertion: content the textarea1055 // Always suppress the browser's default insertion: content the textarea
994 // would receive (for example an html-only clipboard converted to text)1056 // would receive (for example an html-only clipboard converted to text)
...@@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface {...@@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface {
1384 this.input.addEventListener("blur", this.onBlur);1446 this.input.addEventListener("blur", this.onBlur);
1385 this.input.addEventListener("input", this.onInput);1447 this.input.addEventListener("input", this.onInput);
1386 this.input.addEventListener("paste", this.onPaste);1448 this.input.addEventListener("paste", this.onPaste);
1449 this.input.addEventListener("copy", this.onCopyEvent);
1387 this.input.addEventListener("compositionstart", this.onCompositionStart);1450 this.input.addEventListener("compositionstart", this.onCompositionStart);
1388 this.input.addEventListener("compositionend", this.onCompositionEnd);1451 this.input.addEventListener("compositionend", this.onCompositionEnd);
1389 this.canvas.addEventListener("pointerdown", this.onPointerDown);1452 this.canvas.addEventListener("pointerdown", this.onPointerDown);
...@@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface {...@@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface {
1408 this.input.removeEventListener("blur", this.onBlur);1471 this.input.removeEventListener("blur", this.onBlur);
1409 this.input.removeEventListener("input", this.onInput);1472 this.input.removeEventListener("input", this.onInput);
1410 this.input.removeEventListener("paste", this.onPaste);1473 this.input.removeEventListener("paste", this.onPaste);
1474 this.input.removeEventListener("copy", this.onCopyEvent);
1411 this.input.removeEventListener("compositionstart", this.onCompositionStart);1475 this.input.removeEventListener("compositionstart", this.onCompositionStart);
1412 this.input.removeEventListener("compositionend", this.onCompositionEnd);1476 this.input.removeEventListener("compositionend", this.onCompositionEnd);
1413 this.canvas.removeEventListener("pointerdown", this.onPointerDown);1477 this.canvas.removeEventListener("pointerdown", this.onPointerDown);
packages/contracts/src/ipc.ts+1
...@@ -1189,6 +1189,7 @@ export interface LocalApi {...@@ -1189,6 +1189,7 @@ export interface LocalApi {
1189 items: readonly ContextMenuItem<T>[],1189 items: readonly ContextMenuItem<T>[],
1190 position?: { x: number; y: number },1190 position?: { x: number; y: number },
1191 ) => Promise<T | null>;1191 ) => Promise<T | null>;
1192 close: () => Promise<void>;
1192 };1193 };
1193 persistence: {1194 persistence: {
1194 getClientSettings: () => Promise<ClientSettings | null>;1195 getClientSettings: () => Promise<ClientSettings | null>;