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 {...@@ -13,6 +13,7 @@ import {
13 XIcon,13 XIcon,
14} from "lucide-react";14} from "lucide-react";
15import {15import {
16 type ContextMenuItem,
16 type ResolvedKeybindingsConfig,17 type ResolvedKeybindingsConfig,
17 type ScopedThreadRef,18 type ScopedThreadRef,
18 type ThreadId,19 type ThreadId,
...@@ -32,7 +33,7 @@ import {...@@ -32,7 +33,7 @@ import {
32} from "react";33} from "react";
33import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";34import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
34import { Button } from "~/components/ui/button";35import { Button } from "~/components/ui/button";
35import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";36import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";
36import { cn } from "~/lib/utils";37import { cn } from "~/lib/utils";
37import { type TerminalContextSelection } from "~/lib/terminalContext";38import { type TerminalContextSelection } from "~/lib/terminalContext";
38import {39import {
...@@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: {...@@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: {
255 };256 };
256}257}
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
258export function shouldHandleTerminalExit(302export function shouldHandleTerminalExit(
259 current: TerminalSessionState["status"],303 current: TerminalSessionState["status"],
260 synchronized: TerminalSessionState["status"],304 synchronized: TerminalSessionState["status"],
...@@ -328,7 +372,10 @@ export function TerminalViewport({...@@ -328,7 +372,10 @@ export function TerminalViewport({
328 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);372 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
329 const selectionGestureActiveRef = useRef(false);373 const selectionGestureActiveRef = useRef(false);
330 const selectionActionRequestIdRef = useRef(0);374 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);
332 const selectionActionTimerRef = useRef<number | null>(null);379 const selectionActionTimerRef = useRef<number | null>(null);
333 const keybindingsRef = useRef(keybindings);380 const keybindingsRef = useRef(keybindings);
334 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);381 const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]);
...@@ -443,6 +490,12 @@ export function TerminalViewport({...@@ -443,6 +490,12 @@ export function TerminalViewport({
443 onSelectionChange: () => handleSelectionChange(),490 onSelectionChange: () => handleSelectionChange(),
444 beforeKey: (event) => handleBeforeKey(event),491 beforeKey: (event) => handleBeforeKey(event),
445 onLinkActivate: (text, event) => handleLinkActivate(text, event),492 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 },
446 };499 };
447 const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions);500 const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions);
448 if (cancelled) {501 if (cancelled) {
...@@ -518,12 +571,98 @@ export function TerminalViewport({...@@ -518,12 +571,98 @@ export function TerminalViewport({
518 };571 };
519 };572 };
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
521 const showSelectionAction = async () => {660 const showSelectionAction = async () => {
522 if (!localApi) {661 if (!localApi) {
523 clearSelectionAction();662 clearSelectionAction();
524 return;663 return;
525 }664 }
526 if (selectionActionMenuOpenRef.current) {665 if (openSelectionMenuRequestIdRef.current !== null) {
527 return;666 return;
528 }667 }
529 const nextAction = readSelectionAction();668 const nextAction = readSelectionAction();
...@@ -532,45 +671,23 @@ export function TerminalViewport({...@@ -532,45 +671,23 @@ export function TerminalViewport({
532 return;671 return;
533 }672 }
534 const requestId = ++selectionActionRequestIdRef.current;673 const requestId = ++selectionActionRequestIdRef.current;
535 selectionActionMenuOpenRef.current = true;674 openSelectionMenuRequestIdRef.current = requestId;
536 const clicked = await localApi.contextMenu675 const clicked = await localApi.contextMenu
537 .show(676 .show(terminalSelectionMenuItems(), nextAction.position)
538 [
539 { id: "add-to-chat", label: "Add to chat" },
540 { id: "copy", label: "Copy" },
541 ],
542 nextAction.position,
543 )
544 .finally(() => {677 .finally(() => {
545 selectionActionMenuOpenRef.current = false;678 if (openSelectionMenuRequestIdRef.current === requestId) {
679 openSelectionMenuRequestIdRef.current = null;
680 }
546 });681 });
547 if (requestId !== selectionActionRequestIdRef.current || clicked === null) {682 if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
548 return;683 return;
549 }684 }
550 switch (clicked) {685 switch (clicked) {
551 case "add-to-chat":686 case "add-to-chat":
552 handleAddTerminalContext(nextAction.selection);687 addSelectionToChat(nextAction.selection);
553 terminalRef.current?.clearSelection();
554 terminalRef.current?.focus();
555 return;688 return;
556 case "copy":689 case "copy":
557 try {690 await copySelection(nextAction.clipboardText, requestId);
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 }
574 return;691 return;
575 }692 }
576 };693 };
...@@ -684,11 +801,17 @@ export function TerminalViewport({...@@ -684,11 +801,17 @@ export function TerminalViewport({
684 if (terminalRef.current?.hasSelection()) {801 if (terminalRef.current?.hasSelection()) {
685 return;802 return;
686 }803 }
804 const shouldClear = shouldClearTerminalSelectionAction({
805 timerPending: selectionActionTimerRef.current !== null,
806 openMenuRequestId: openSelectionMenuRequestIdRef.current,
807 currentRequestId: selectionActionRequestIdRef.current,
808 });
809 if (!shouldClear) return;
687 clearSelectionAction();810 clearSelectionAction();
688 // A copy shortcut that clears the selection (Ctrl+C) must also close811 // A copy shortcut that clears the selection (Ctrl+C) must also close
689 // the context menu that appears with the selection, but a clear that812 // the context menu that appears with the selection, but a clear that
690 // never opened a menu must not dismiss an unrelated one.813 // never opened a menu must not dismiss an unrelated one.
691 if (selectionActionMenuOpenRef.current) {814 if (openSelectionMenuRequestIdRef.current !== null) {
692 void localApi?.contextMenu.close();815 void localApi?.contextMenu.close();
693 }816 }
694 }817 }
apps/web/src/hooks/useCopyToClipboard.ts+44
...@@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteE...@@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteE
24 }24 }
25}25}
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
27export async function writeTextToClipboard(value: string, target = "text") {50export async function writeTextToClipboard(value: string, target = "text") {
28 if (51 if (
29 typeof window === "undefined" ||52 typeof window === "undefined" ||
...@@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") {...@@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") {
48 }71 }
49}72}
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
51export function useCopyToClipboard<TContext = void>({95export function useCopyToClipboard<TContext = void>({
52 timeout = 2000,96 timeout = 2000,
53 target = "text",97 target = "text",
apps/web/src/terminal/ghostty/surface.ts+30
...@@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions {...@@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions {
465 readonly onSelectionChange: () => void;465 readonly onSelectionChange: () => void;
466 readonly beforeKey: (event: KeyboardEvent) => boolean;466 readonly beforeKey: (event: KeyboardEvent) => boolean;
467 readonly onLinkActivate: (text: string, event: MouseEvent) => void;467 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;
468}474}
469475
470export class GhosttyTerminalSurface {476export class GhosttyTerminalSurface {
...@@ -801,6 +807,28 @@ export class GhosttyTerminalSurface {...@@ -801,6 +807,28 @@ export class GhosttyTerminalSurface {
801 this.input.focus({ preventScroll: true });807 this.input.focus({ preventScroll: true });
802 }808 }
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
804 hasSelection(): boolean {832 hasSelection(): boolean {
805 return this.core.selectionText().length > 0;833 return this.core.selectionText().length > 0;
806 }834 }
...@@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface {...@@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface {
1373 private readonly onContextMenu = (event: MouseEvent) => {1401 private readonly onContextMenu = (event: MouseEvent) => {
1374 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {1402 if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1375 event.preventDefault();1403 event.preventDefault();
1404 return;
1376 }1405 }
1406 this.options.onContextMenu?.(event);
1377 };1407 };
13781408
1379 private readonly onScrollbarPointerDown = (event: PointerEvent) => {1409 private readonly onScrollbarPointerDown = (event: PointerEvent) => {