authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-15 13:44:37+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 11:44:37+00:00
log270489b887420db3319898ab4046516e4c457711
tree6cd1d72b136e892a0140e270e84d66c59c671bbf
parenta38cac81d82b82a6967eaf8cb90ed2770c514f3c
signature Signed by PGP key B5690EEEBB952194

fix(terminal): right-click paste works in the terminal (#5240)


3 files changed, 230 insertions(+), 33 deletions(-)

apps/web/src/components/ThreadTerminalDrawer.tsx+156-33
......@@ -13,6 +13,7 @@ import {
1313 XIcon,
1414} from "lucide-react";
1515import {
16 type ContextMenuItem,
1617 type ResolvedKeybindingsConfig,
1718 type ScopedThreadRef,
1819 type ThreadId,
......@@ -32,7 +33,7 @@ import {
3233} from "react";
3334import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3435import { Button } from "~/components/ui/button";
35import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
36import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
3637import { cn } from "~/lib/utils";
3738import { type TerminalContextSelection } from "~/lib/terminalContext";
3839import {
......@@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: {
255256 };
256257}
257258
259export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
260
261/** Post-selection popup: just the two selection actions, always enabled. */
262export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {
263 return [
264 { id: "add-to-chat", label: "Add to chat" },
265 { id: "copy", label: "Copy" },
266 ];
267}
268
269/**
270 * Right-click menu for the terminal canvas: the selection actions (disabled
271 * until a selection exists) plus Paste. Paste is always offered: the browser
272 * (and Electron's default editing menu) can only paste into an editable
273 * element, so a canvas terminal never gets a usable entry from them.
274 */
275export function terminalContextMenuItems(options: {
276 hasSelection: boolean;
277}): ContextMenuItem<TerminalContextMenuAction>[] {
278 return [
279 ...terminalSelectionMenuItems().map((item) => ({
280 ...item,
281 disabled: !options.hasSelection,
282 })),
283 { id: "paste", label: "Paste" },
284 ];
285}
286
287/**
288 * An empty selection change may only cancel a selection-action flow that is
289 * still current: a pending popup timer, or an open popup whose request id has
290 * not been superseded. A popup already superseded by a right-click keeps its
291 * menu promise unsettled for a moment; treating it as active would cancel the
292 * newer context-menu flow instead.
293 */
294export function shouldClearTerminalSelectionAction(options: {
295 timerPending: boolean;
296 openMenuRequestId: number | null;
297 currentRequestId: number;
298}): boolean {
299 return options.timerPending || options.openMenuRequestId === options.currentRequestId;
300}
301
258302export function shouldHandleTerminalExit(
259303 current: TerminalSessionState["status"],
260304 synchronized: TerminalSessionState["status"],
......@@ -328,7 +372,10 @@ export function TerminalViewport({
328372 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
329373 const selectionGestureActiveRef = useRef(false);
330374 const selectionActionRequestIdRef = useRef(0);
331 const selectionActionMenuOpenRef = useRef(false);
375 // Holds the request id of the selection popup currently on screen, so a
376 // popup that was superseded (but whose menu promise has not settled yet)
377 // cannot be mistaken for the active flow.
378 const openSelectionMenuRequestIdRef = useRef<number | null>(null);
332379 const selectionActionTimerRef = useRef<number | null>(null);
333380 const keybindingsRef = useRef(keybindings);
334381 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
......@@ -443,6 +490,12 @@ export function TerminalViewport({
443490 onSelectionChange: () => handleSelectionChange(),
444491 beforeKey: (event) => handleBeforeKey(event),
445492 onLinkActivate: (text, event) => handleLinkActivate(text, event),
493 // The surface listens from construction, so a right-click can land
494 // while `create` is still awaiting WASM — before the handler below it
495 // exists. The ref is only assigned once that setup has run.
496 onContextMenu: (event) => {
497 if (terminalRef.current) void showTerminalContextMenu(event);
498 },
446499 };
447500 const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions);
448501 if (cancelled) {
......@@ -518,12 +571,98 @@ export function TerminalViewport({
518571 };
519572 };
520573
574 const addSelectionToChat = (selection: TerminalContextSelection) => {
575 handleAddTerminalContext(selection);
576 terminalRef.current?.clearSelection();
577 terminalRef.current?.focus();
578 };
579
580 // A selection-action flow that was superseded while its async work ran
581 // must go silent: no error message, no focus steal.
582 const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => {
583 if (requestId !== selectionActionRequestIdRef.current) return;
584 const activeTerminal = terminalRef.current;
585 if (activeTerminal) {
586 writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback);
587 }
588 };
589
590 const focusIfCurrent = (requestId: number) => {
591 if (requestId === selectionActionRequestIdRef.current) {
592 terminalRef.current?.focus();
593 }
594 };
595
596 const copySelection = async (text: string, requestId: number) => {
597 try {
598 await writeTextToClipboard(text, "terminal selection");
599 } catch (error) {
600 reportIfCurrent(requestId, error, "Unable to copy terminal selection");
601 }
602 focusIfCurrent(requestId);
603 };
604
605 const pasteFromClipboard = async (requestId: number) => {
606 const activeTerminal = terminalRef.current;
607 if (!activeTerminal) return;
608 try {
609 // The surface owns the read so it can claim the paste race before it
610 // starts: a paste shortcut fired while the menu read is in flight
611 // supersedes this paste instead of landing alongside it.
612 await activeTerminal.pasteFromClipboard(
613 () => readTextFromClipboard("terminal input"),
614 () => requestId === selectionActionRequestIdRef.current,
615 );
616 } catch (error) {
617 reportIfCurrent(requestId, error, "Unable to read the clipboard");
618 return;
619 }
620 focusIfCurrent(requestId);
621 };
622
623 const showTerminalContextMenu = async (event: MouseEvent) => {
624 if (!localApi || !terminalRef.current) return;
625 // Own the gesture before anything async: leaving the default alive lets
626 // the browser (or Electron's editing menu) answer with a Paste entry
627 // that is permanently disabled over the terminal canvas.
628 event.preventDefault();
629 // A right-click supersedes a selection popup that is pending or open.
630 clearSelectionAction();
631 const selectionAction = readSelectionAction();
632 const requestId = selectionActionRequestIdRef.current;
633 let clicked: TerminalContextMenuAction | null;
634 try {
635 clicked = await localApi.contextMenu.show(
636 terminalContextMenuItems({ hasSelection: selectionAction !== null }),
637 { x: event.clientX, y: event.clientY },
638 );
639 } catch (error) {
640 reportIfCurrent(requestId, error, "Unable to open the terminal context menu");
641 focusIfCurrent(requestId);
642 return;
643 }
644 if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
645 return;
646 }
647 switch (clicked) {
648 case "add-to-chat":
649 if (selectionAction) addSelectionToChat(selectionAction.selection);
650 return;
651 case "copy":
652 if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
653 return;
654 case "paste":
655 await pasteFromClipboard(requestId);
656 return;
657 }
658 };
659
521660 const showSelectionAction = async () => {
522661 if (!localApi) {
523662 clearSelectionAction();
524663 return;
525664 }
526 if (selectionActionMenuOpenRef.current) {
665 if (openSelectionMenuRequestIdRef.current !== null) {
527666 return;
528667 }
529668 const nextAction = readSelectionAction();
......@@ -532,45 +671,23 @@ export function TerminalViewport({
532671 return;
533672 }
534673 const requestId = ++selectionActionRequestIdRef.current;
535 selectionActionMenuOpenRef.current = true;
674 openSelectionMenuRequestIdRef.current = requestId;
536675 const clicked = await localApi.contextMenu
537 .show(
538 [
539 { id: "add-to-chat", label: "Add to chat" },
540 { id: "copy", label: "Copy" },
541 ],
542 nextAction.position,
543 )
676 .show(terminalSelectionMenuItems(), nextAction.position)
544677 .finally(() => {
545 selectionActionMenuOpenRef.current = false;
678 if (openSelectionMenuRequestIdRef.current === requestId) {
679 openSelectionMenuRequestIdRef.current = null;
680 }
546681 });
547682 if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
548683 return;
549684 }
550685 switch (clicked) {
551686 case "add-to-chat":
552 handleAddTerminalContext(nextAction.selection);
553 terminalRef.current?.clearSelection();
554 terminalRef.current?.focus();
687 addSelectionToChat(nextAction.selection);
555688 return;
556689 case "copy":
557 try {
558 await writeTextToClipboard(nextAction.clipboardText, "terminal selection");
559 } catch (error) {
560 if (requestId !== selectionActionRequestIdRef.current) {
561 return;
562 }
563 const activeTerminal = terminalRef.current;
564 if (activeTerminal) {
565 writeSystemMessage(
566 activeTerminal,
567 error instanceof Error ? error.message : "Unable to copy terminal selection",
568 );
569 }
570 }
571 if (requestId === selectionActionRequestIdRef.current) {
572 terminalRef.current?.focus();
573 }
690 await copySelection(nextAction.clipboardText, requestId);
574691 return;
575692 }
576693 };
......@@ -684,11 +801,17 @@ export function TerminalViewport({
684801 if (terminalRef.current?.hasSelection()) {
685802 return;
686803 }
804 const shouldClear = shouldClearTerminalSelectionAction({
805 timerPending: selectionActionTimerRef.current !== null,
806 openMenuRequestId: openSelectionMenuRequestIdRef.current,
807 currentRequestId: selectionActionRequestIdRef.current,
808 });
809 if (!shouldClear) return;
687810 clearSelectionAction();
688811 // A copy shortcut that clears the selection (Ctrl+C) must also close
689812 // the context menu that appears with the selection, but a clear that
690813 // never opened a menu must not dismiss an unrelated one.
691 if (selectionActionMenuOpenRef.current) {
814 if (openSelectionMenuRequestIdRef.current !== null) {
692815 void localApi?.contextMenu.close();
693816 }
694817 }
apps/web/src/hooks/useCopyToClipboard.ts+44
......@@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteE
2424 }
2525}
2626
27export class ClipboardReadUnavailableError extends Schema.TaggedErrorClass<ClipboardReadUnavailableError>()(
28 "ClipboardReadUnavailableError",
29 {
30 target: Schema.String,
31 },
32) {
33 override get message(): string {
34 return `Clipboard API is unavailable while reading ${this.target}.`;
35 }
36}
37
38export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadError>()(
39 "ClipboardReadError",
40 {
41 target: Schema.String,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Failed to read ${this.target} from the clipboard.`;
47 }
48}
49
2750export async function writeTextToClipboard(value: string, target = "text") {
2851 if (
2952 typeof window === "undefined" ||
......@@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") {
4871 }
4972}
5073
74export async function readTextFromClipboard(target = "text"): Promise<string> {
75 if (
76 typeof window === "undefined" ||
77 typeof navigator === "undefined" ||
78 !navigator.clipboard?.readText
79 ) {
80 throw new ClipboardReadUnavailableError({
81 target,
82 });
83 }
84
85 try {
86 return await navigator.clipboard.readText();
87 } catch (cause) {
88 throw new ClipboardReadError({
89 target,
90 cause,
91 });
92 }
93}
94
5195export function useCopyToClipboard<TContext = void>({
5296 timeout = 2000,
5397 target = "text",
apps/web/src/terminal/ghostty/surface.ts+30
......@@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions {
465465 readonly onSelectionChange: () => void;
466466 readonly beforeKey: (event: KeyboardEvent) => boolean;
467467 readonly onLinkActivate: (text: string, event: MouseEvent) => void;
468 /**
469 * A right-click the running application did not claim through mouse
470 * reporting. The host owns the menu, so it also owns preventing the browser
471 * default — whose Paste entry can never reach a canvas terminal.
472 */
473 readonly onContextMenu?: (event: MouseEvent) => void;
468474}
469475
470476export class GhosttyTerminalSurface {
......@@ -801,6 +807,28 @@ export class GhosttyTerminalSurface {
801807 this.input.focus({ preventScroll: true });
802808 }
803809
810 /**
811 * Pastes clipboard text read by the host (context menu) with the same
812 * bracketed-paste encoding as a native paste event. The read joins the same
813 * race the paste shortcut uses — the token is claimed before it starts — so
814 * a shortcut or native paste arriving during the read supersedes this one
815 * instead of both reaching the shell.
816 */
817 async pasteFromClipboard(
818 readText: () => Promise<string>,
819 isCurrent: () => boolean = () => true,
820 ): Promise<void> {
821 const token = ++this.pasteShortcutToken;
822 const text = await readText();
823 if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return;
824 // As in every paste path, delivering bumps the token so a clipboard read
825 // still in flight cannot land after this text reaches the shell.
826 this.pasteShortcutToken += 1;
827 if (text.length === 0) return;
828 const encoded = this.core.encodePaste(text);
829 if (encoded.length > 0) this.options.onData(encoded);
830 }
831
804832 hasSelection(): boolean {
805833 return this.core.selectionText().length > 0;
806834 }
......@@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface {
13731401 private readonly onContextMenu = (event: MouseEvent) => {
13741402 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
13751403 event.preventDefault();
1404 return;
13761405 }
1406 this.options.onContextMenu?.(event);
13771407 };
13781408
13791409 private readonly onScrollbarPointerDown = (event: PointerEvent) => {