Skip to content
Closed
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
8 changes: 7 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,16 @@ export const make = Effect.gen(function* () {
menuTemplate.push({ type: "separator" });
}

// The terminal renders on a canvas, but it focuses a hidden textarea so
// Electron's Paste role can send clipboard text to that input. Electron
// reports the canvas itself as non-editable, so enable Paste for canvases.
menuTemplate.push(
{ role: "cut", enabled: params.editFlags.canCut },
{ role: "copy", enabled: params.editFlags.canCopy },
{ role: "paste", enabled: params.editFlags.canPaste },
{
role: "paste",
enabled: params.editFlags.canPaste || params.mediaType === "canvas",
},
{ role: "selectAll", enabled: params.editFlags.canSelectAll },
);

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/terminal/ghostty/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,12 @@ describe("isTerminalPasteShortcut", () => {
expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false);
});

it("preserves Ctrl+V and uses Ctrl+Shift+V elsewhere", () => {
it("uses Ctrl+V and Ctrl+Shift+V on Windows", () => {
expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Win32")).toBe(true);
expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(true);
});

it("preserves Ctrl+V and uses Ctrl+Shift+V on Linux", () => {
expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false);
expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(
true,
Expand Down
58 changes: 56 additions & 2 deletions apps/web/src/terminal/ghostty/surface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isMacPlatform } from "../../lib/utils";
import { isMacPlatform, isWindowsPlatform } from "../../lib/utils";
import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";
import {
GhosttyTerminalCore,
Expand Down Expand Up @@ -33,6 +33,7 @@ const TERMINAL_GLYPH_FALLBACKS =
export const DEFAULT_TERMINAL_FONT_FAMILY =
'"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", ' + TERMINAL_GLYPH_FALLBACKS;
const CONTENT_PADDING = 4;
const CONTEXT_MENU_INPUT_SIZE = 20;
const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
/** Half a blink cycle: the visible and hidden phases are equally long. */
const CURSOR_BLINK_INTERVAL_MS = 500;
Expand All @@ -44,6 +45,15 @@ const TERMINAL_FONT_LOAD_VARIANTS = [
"italic 700",
] as const;

interface TerminalInputStyleSnapshot {
readonly left: string;
readonly top: string;
readonly width: string;
readonly height: string;
readonly zIndex: string;
readonly pointerEvents: string;
}

/** Requested terminal font; omitted fields fall back to the defaults. */
export interface GhosttyTerminalFont {
readonly family?: string;
Expand Down Expand Up @@ -341,7 +351,8 @@ export function isTerminalPasteShortcut(
platform = navigator.platform,
) {
if (event.key.toLowerCase() !== "v") return false;
return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
if (isMacPlatform(platform)) return event.metaKey;
return event.ctrlKey && (isWindowsPlatform(platform) || event.shiftKey);
}

export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
Expand Down Expand Up @@ -538,6 +549,7 @@ export class GhosttyTerminalSurface {
private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)");
private inputLeft = -1;
private inputTop = -1;
private contextMenuInputStyle: TerminalInputStyleSnapshot | null = null;

private constructor(
mount: HTMLElement,
Expand Down Expand Up @@ -796,6 +808,7 @@ export class GhosttyTerminalSurface {
}

focus(): void {
this.restoreInputAfterContextMenu();
this.input.focus({ preventScroll: true });
}

Expand Down Expand Up @@ -990,6 +1003,7 @@ export class GhosttyTerminalSurface {
}

private readonly onPaste = (event: ClipboardEvent) => {
this.restoreInputAfterContextMenu();
// Always suppress the browser's default insertion: content the textarea
// would receive (for example an html-only clipboard converted to text)
// leaks through onInput without bracketed-paste encoding.
Expand Down Expand Up @@ -1310,7 +1324,34 @@ export class GhosttyTerminalSurface {
private readonly onContextMenu = (event: MouseEvent) => {
if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
event.preventDefault();
return;
}

// Native browser and Electron menus enable Paste for the focused editable
// element. Put the hidden input under the pointer before the menu opens so
// the terminal gets the platform's normal context-menu Paste action.
this.restoreInputAfterContextMenu();
this.contextMenuInputStyle = {
left: this.input.style.left,
top: this.input.style.top,
width: this.input.style.width,
height: this.input.style.height,
zIndex: this.input.style.zIndex,
pointerEvents: this.input.style.pointerEvents,
};
const bounds = this.mount.getBoundingClientRect();
const left = event.clientX - bounds.left - CONTEXT_MENU_INPUT_SIZE / 2;
const top = event.clientY - bounds.top - CONTEXT_MENU_INPUT_SIZE / 2;
Comment on lines +1342 to +1344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Implement context-menu paste for browser clients

In the web and locally hosted browser clients, the native context menu remains associated with the canvas that received this contextmenu event; moving and focusing a textarea after dispatch does not change that event target or its non-editable menu state. Unlike desktop, where DesktopWindow.ts explicitly enables Electron's Paste role for canvases, these clients will therefore still show Paste as unavailable. Use a renderer-owned context-menu action or another clipboard path that does not depend on changing the hit-tested element after the event.

AGENTS.md reference: AGENTS.md:L67-L70

Useful? React with 👍 / 👎.

this.input.style.left = `${left}px`;
this.input.style.top = `${top}px`;
this.input.style.width = `${CONTEXT_MENU_INPUT_SIZE}px`;
this.input.style.height = `${CONTEXT_MENU_INPUT_SIZE}px`;
this.input.style.zIndex = "1000";
this.input.style.pointerEvents = "auto";
this.input.focus({ preventScroll: true });
window.setTimeout(() => {
if (!this.disposed) this.restoreInputAfterContextMenu();
}, 0);
};

private readonly onScrollbarPointerDown = (event: PointerEvent) => {
Expand Down Expand Up @@ -1569,6 +1610,7 @@ export class GhosttyTerminalSurface {
}

private positionInput(): void {
if (this.contextMenuInputStyle !== null) return;
const snapshot = this.snapshot;
if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) {
return;
Expand All @@ -1585,6 +1627,18 @@ export class GhosttyTerminalSurface {
this.input.style.height = `${this.metrics.height}px`;
}

private restoreInputAfterContextMenu(): void {
const style = this.contextMenuInputStyle;
if (style === null) return;
this.contextMenuInputStyle = null;
this.input.style.left = style.left;
this.input.style.top = style.top;
this.input.style.width = style.width;
this.input.style.height = style.height;
this.input.style.zIndex = style.zIndex;
this.input.style.pointerEvents = style.pointerEvents;
}
Comment thread
cursor[bot] marked this conversation as resolved.

private cellAt(clientX: number, clientY: number): { x: number; y: number } {
const bounds = this.canvas.getBoundingClientRect();
return {
Expand Down
Loading