From 518b95f81313d08f2d85c40a28ff5b576c82e397 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:12:34 +0200 Subject: [PATCH 1/4] fix(terminal): offer paste on the terminal right-click menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking the terminal with nothing selected offered no way to paste. The selection popup only appears after text is selected, so an empty prompt fell through to the platform default — and both defaults key their Paste entry on the right-clicked node being editable. The terminal is a canvas, so Electron's editing menu rendered Paste greyed out and the browser menu offered none at all, even though the shell was focused and ready for input. The surface now hands unclaimed right-clicks to its host (mouse-reporting sessions still consume them, unchanged), and the drawer answers with its own menu: Add to chat and Copy stay selection-only, Paste is always available and writes through Ghostty's bracketed-paste encoding — the same path the native paste event and the paste shortcut already take. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/ThreadTerminalDrawer.test.ts | 17 +++ .../src/components/ThreadTerminalDrawer.tsx | 130 +++++++++++++++--- apps/web/src/hooks/useCopyToClipboard.ts | 44 ++++++ apps/web/src/terminal/ghostty/surface.ts | 20 +++ 4 files changed, 190 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678..d04791c9e6d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -4,6 +4,7 @@ import { resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, + terminalContextMenuItems, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; @@ -89,4 +90,20 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false); expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); }); + + it("offers paste on the right-click menu even with nothing selected", () => { + expect(terminalContextMenuItems({ hasSelection: false })).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: true }, + { id: "copy", label: "Copy", disabled: true }, + { id: "paste", label: "Paste" }, + ]); + }); + + it("enables the selection actions once the terminal has a selection", () => { + expect(terminalContextMenuItems({ hasSelection: true })).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: false }, + { id: "copy", label: "Copy", disabled: false }, + { id: "paste", label: "Paste" }, + ]); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index dd7da738626..866de96d216 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -30,7 +31,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -223,6 +224,23 @@ export function terminalSelectionLineRange(position: { }; } +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +/** + * Right-click menu for the terminal canvas. Paste is always offered: the + * browser (and Electron's default editing menu) can only paste into an + * editable element, so a canvas terminal never gets a usable entry from them. + */ +export function terminalContextMenuItems(options: { + hasSelection: boolean; +}): ContextMenuItem[] { + return [ + { id: "add-to-chat", label: "Add to chat", disabled: !options.hasSelection }, + { id: "copy", label: "Copy", disabled: !options.hasSelection }, + { id: "paste", label: "Paste" }, + ]; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -386,6 +404,12 @@ export function TerminalViewport({ onCopy: (text) => handleCopy(text), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + // The surface listens from construction, so a right-click can land + // while `create` is still awaiting WASM — before the handler below it + // exists. The ref is only assigned once that setup has run. + onContextMenu: (event) => { + if (terminalRef.current) void showTerminalContextMenu(event); + }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); if (cancelled) { @@ -454,6 +478,88 @@ export function TerminalViewport({ }; }; + const addSelectionToChat = (selection: TerminalContextSelection) => { + handleAddTerminalContext(selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + }; + + const copySelection = async (text: string, requestId: number) => { + try { + await writeTextToClipboard(text, "terminal selection"); + } catch (error) { + if (requestId !== selectionActionRequestIdRef.current) { + return; + } + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } + } + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); + } + }; + + const pasteFromClipboard = async (requestId: number) => { + let text: string; + try { + text = await readTextFromClipboard("terminal input"); + } catch (error) { + if (requestId !== selectionActionRequestIdRef.current) { + return; + } + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to read the clipboard", + ); + } + return; + } + if (requestId !== selectionActionRequestIdRef.current) { + return; + } + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + activeTerminal.paste(text); + activeTerminal.focus(); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi || !terminalRef.current) return; + // Own the gesture before anything async: leaving the default alive lets + // the browser (or Electron's editing menu) answer with a Paste entry + // that is permanently disabled over the terminal canvas. + event.preventDefault(); + // A right-click supersedes a selection popup that is pending or open. + clearSelectionAction(); + const selectionAction = readSelectionAction(); + const requestId = selectionActionRequestIdRef.current; + const clicked = await localApi.contextMenu.show( + terminalContextMenuItems({ hasSelection: selectionAction !== null }), + { x: event.clientX, y: event.clientY }, + ); + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + if (selectionAction) addSelectionToChat(selectionAction.selection); + return; + case "copy": + if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); + return; + case "paste": + await pasteFromClipboard(requestId); + return; + } + }; + const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); @@ -485,28 +591,10 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + addSelectionToChat(nextAction.selection); return; case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } + await copySelection(nextAction.clipboardText, requestId); return; } }; diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d6593..ef66410f7db 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 86c1ad5329c..70c003a07d1 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -318,6 +318,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onCopy: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -615,6 +621,18 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Sends clipboard text through the same bracketed-paste encoding as a native + * paste event, for hosts that read the clipboard themselves (context menu). + */ + paste(text: string): void { + if (text.length === 0) return; + // Settle the paste-shortcut race exactly like the native paste event does, + // so a clipboard read still in flight cannot deliver the text twice. + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(text)); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1066,7 +1084,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { From 0ab8f9e0d344bf085e69e7a9cabe6f909be8f1b5 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:37:31 +0200 Subject: [PATCH 2/4] fix(terminal): join the menu paste to the shortcut paste race The context-menu paste read the clipboard outside the token protocol and only claimed it once the text was already in hand. A paste shortcut or native paste event arriving during that read therefore could not supersede it, so an ordering where the shortcut resolved first delivered both. The surface now owns the read: `pasteFromClipboard` claims the token before starting it and drops its own result if anything paste-like landed meanwhile, matching how the shortcut path already yields to the native paste event. The newest gesture wins and exactly one delivery reaches the shell. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/ThreadTerminalDrawer.tsx | 22 +++++++++---------- apps/web/src/terminal/ghostty/surface.ts | 16 +++++++++----- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 866de96d216..0abf07b831b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -505,29 +505,29 @@ export function TerminalViewport({ }; const pasteFromClipboard = async (requestId: number) => { - let text: string; + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; try { - text = await readTextFromClipboard("terminal input"); + // The surface owns the read so it can claim the paste race before it + // starts: a paste shortcut fired while the menu read is in flight + // supersedes this paste instead of landing alongside it. + await activeTerminal.pasteFromClipboard(() => readTextFromClipboard("terminal input")); } catch (error) { if (requestId !== selectionActionRequestIdRef.current) { return; } - const activeTerminal = terminalRef.current; - if (activeTerminal) { + const latestTerminal = terminalRef.current; + if (latestTerminal) { writeSystemMessage( - activeTerminal, + latestTerminal, error instanceof Error ? error.message : "Unable to read the clipboard", ); } return; } - if (requestId !== selectionActionRequestIdRef.current) { - return; + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); } - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - activeTerminal.paste(text); - activeTerminal.focus(); }; const showTerminalContextMenu = async (event: MouseEvent) => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 70c003a07d1..328bb8083f2 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -622,14 +622,18 @@ export class GhosttyTerminalSurface { } /** - * Sends clipboard text through the same bracketed-paste encoding as a native - * paste event, for hosts that read the clipboard themselves (context menu). + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. */ - paste(text: string): void { - if (text.length === 0) return; - // Settle the paste-shortcut race exactly like the native paste event does, - // so a clipboard read still in flight cannot deliver the text twice. + async pasteFromClipboard(readText: () => Promise): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; + if (text.length === 0) return; this.options.onData(this.core.encodePaste(text)); } From d34567e7eb0d44a6b04c8ee40dc30afc054f1484 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:06:02 +0200 Subject: [PATCH 3/4] fix(terminal): leave space after context-menu paste --- apps/web/src/terminal/ghostty/surface.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 328bb8083f2..f2f94997e4a 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -634,7 +634,11 @@ export class GhosttyTerminalSurface { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; if (text.length === 0) return; - this.options.onData(this.core.encodePaste(text)); + // Keep the space outside Ghostty's bracketed-paste delimiters. This lets + // shells highlight exactly the pasted text while leaving the cursor ready + // for the next argument, matching the terminal's context-menu behavior. + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(`${encoded} `); } hasSelection(): boolean { From 00c727204f2cf63afefdae5e820bf3092e6f1af8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:10:44 +0200 Subject: [PATCH 4/4] fix(terminal): preserve exact context-menu paste --- apps/web/src/terminal/ghostty/surface.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index f2f94997e4a..4e8fb4abd47 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -634,11 +634,8 @@ export class GhosttyTerminalSurface { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; if (text.length === 0) return; - // Keep the space outside Ghostty's bracketed-paste delimiters. This lets - // shells highlight exactly the pasted text while leaving the cursor ready - // for the next argument, matching the terminal's context-menu behavior. const encoded = this.core.encodePaste(text); - if (encoded.length > 0) this.options.onData(`${encoded} `); + if (encoded.length > 0) this.options.onData(encoded); } hasSelection(): boolean {