From cf0073ef0365f8fc81cb502afaa215f5f958411f Mon Sep 17 00:00:00 2001 From: Taylor Bombay Date: Tue, 11 Aug 2026 21:07:08 -0700 Subject: [PATCH 1/2] fix(terminal): restore clipboard paste on Windows and context menus - Enable native Paste for canvas terminals - Support Ctrl+V on Windows and position the hidden input for context menus --- apps/desktop/src/window/DesktopWindow.ts | 8 +++- apps/web/src/terminal/ghostty/surface.test.ts | 7 ++- apps/web/src/terminal/ghostty/surface.ts | 43 ++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c681448fe..ac85bedc13a2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -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 }, ); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 31bc47bdff79..72cd33103bbf 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -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, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index fc7a89c6d31e..886d3423e60f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1,4 +1,4 @@ -import { isMacPlatform } from "../../lib/utils"; +import { isMacPlatform, isWindowsPlatform } from "../../lib/utils"; import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; import { GhosttyTerminalCore, @@ -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; @@ -341,7 +342,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): boolean { @@ -538,6 +540,7 @@ export class GhosttyTerminalSurface { private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); private inputLeft = -1; private inputTop = -1; + private contextMenuInputPositioned = false; private constructor( mount: HTMLElement, @@ -796,6 +799,7 @@ export class GhosttyTerminalSurface { } focus(): void { + this.restoreInputAfterContextMenu(); this.input.focus({ preventScroll: true }); } @@ -990,6 +994,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. @@ -1310,7 +1315,28 @@ 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. + 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; + this.contextMenuInputPositioned = true; + this.inputLeft = left; + this.inputTop = top; + 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) => { @@ -1569,6 +1595,7 @@ export class GhosttyTerminalSurface { } private positionInput(): void { + if (this.contextMenuInputPositioned) return; const snapshot = this.snapshot; if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { return; @@ -1585,6 +1612,18 @@ export class GhosttyTerminalSurface { this.input.style.height = `${this.metrics.height}px`; } + private restoreInputAfterContextMenu(): void { + if (!this.contextMenuInputPositioned) return; + this.contextMenuInputPositioned = false; + this.inputLeft = -1; + this.inputTop = -1; + this.input.style.width = "1px"; + this.input.style.height = "1px"; + this.input.style.zIndex = ""; + this.input.style.pointerEvents = "none"; + this.positionInput(); + } + private cellAt(clientX: number, clientY: number): { x: number; y: number } { const bounds = this.canvas.getBoundingClientRect(); return { From 3dae294c8a70cb8f0e73d07fdd1704bb9670baec Mon Sep 17 00:00:00 2001 From: Taylor Bombay Date: Tue, 11 Aug 2026 21:35:29 -0700 Subject: [PATCH 2/2] fix(terminal): restore input styles after context menu closes --- apps/web/src/terminal/ghostty/surface.ts | 43 ++++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 886d3423e60f..9bceb3ead07d 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -45,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; @@ -540,7 +549,7 @@ export class GhosttyTerminalSurface { private readonly reducedMotionMedia = window.matchMedia?.("(prefers-reduced-motion: reduce)"); private inputLeft = -1; private inputTop = -1; - private contextMenuInputPositioned = false; + private contextMenuInputStyle: TerminalInputStyleSnapshot | null = null; private constructor( mount: HTMLElement, @@ -1321,12 +1330,18 @@ export class GhosttyTerminalSurface { // 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; - this.contextMenuInputPositioned = true; - this.inputLeft = left; - this.inputTop = top; this.input.style.left = `${left}px`; this.input.style.top = `${top}px`; this.input.style.width = `${CONTEXT_MENU_INPUT_SIZE}px`; @@ -1595,7 +1610,7 @@ export class GhosttyTerminalSurface { } private positionInput(): void { - if (this.contextMenuInputPositioned) return; + if (this.contextMenuInputStyle !== null) return; const snapshot = this.snapshot; if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { return; @@ -1613,15 +1628,15 @@ export class GhosttyTerminalSurface { } private restoreInputAfterContextMenu(): void { - if (!this.contextMenuInputPositioned) return; - this.contextMenuInputPositioned = false; - this.inputLeft = -1; - this.inputTop = -1; - this.input.style.width = "1px"; - this.input.style.height = "1px"; - this.input.style.zIndex = ""; - this.input.style.pointerEvents = "none"; - this.positionInput(); + 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; } private cellAt(clientX: number, clientY: number): { x: number; y: number } {