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..0abf07b831b 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) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + try { + // 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 latestTerminal = terminalRef.current; + if (latestTerminal) { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to read the clipboard", + ); + } + return; + } + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.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..328bb8083f2 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,22 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * 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. + */ + 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)); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1066,7 +1088,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) => {