Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
resolveTerminalSelectionActionPosition,
shouldHandleTerminalExit,
shouldHandleTerminalSelectionMouseUp,
terminalContextMenuItems,
terminalSelectionActionDelayForClickCount,
terminalSelectionLineRange,
} from "./ThreadTerminalDrawer";
Expand Down Expand Up @@ -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" },
]);
});
});
130 changes: 109 additions & 21 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
XIcon,
} from "lucide-react";
import {
type ContextMenuItem,
type ResolvedKeybindingsConfig,
type ScopedThreadRef,
type ThreadId,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<TerminalContextMenuAction>[] {
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"],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
};
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteE
}
}

export class ClipboardReadUnavailableError extends Schema.TaggedErrorClass<ClipboardReadUnavailableError>()(
"ClipboardReadUnavailableError",
{
target: Schema.String,
},
) {
override get message(): string {
return `Clipboard API is unavailable while reading ${this.target}.`;
}
}

export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadError>()(
"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" ||
Expand All @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") {
}
}

export async function readTextFromClipboard(target = "text"): Promise<string> {
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<TContext = void>({
timeout = 2000,
target = "text",
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>): Promise<void> {
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;
}
Expand Down Expand Up @@ -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) => {
Expand Down
Loading