From 2229b61593bd813c47166d8708475480597f853d Mon Sep 17 00:00:00 2001 From: ArjandH Date: Thu, 20 Aug 2026 18:06:52 +0200 Subject: [PATCH 1/3] fix(web): Ctrl+C and terminal links work on Windows The Ghostty IME textarea is empty, so native copy wrote a blank clipboard and Electron's Edit menu stole Ctrl+C before SIGINT could reach the PTY. Ctrl+click also waited on a preview menu, so window.open was popup-blocked and left the current browser. Prime the selection, let the renderer own Edit accelerators, and open http links with a same-tick _blank click. Made with Cursor Grok 4.6 --- .../src/window/DesktopApplicationMenu.test.ts | 25 ++++++ .../src/window/DesktopApplicationMenu.ts | 35 +++++++- .../src/components/ThreadTerminalDrawer.tsx | 32 ++----- apps/web/src/lib/openUrlInHostBrowser.test.ts | 64 ++++++++++++++ apps/web/src/lib/openUrlInHostBrowser.ts | 27 ++++++ apps/web/src/localApi.test.ts | 42 ++++++++++ apps/web/src/localApi.ts | 5 +- apps/web/src/terminal/ghostty/surface.test.ts | 56 +++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 84 +++++++++++++++---- docs/architecture/terminal-renderers.md | 4 +- 10 files changed, 330 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/lib/openUrlInHostBrowser.test.ts create mode 100644 apps/web/src/lib/openUrlInHostBrowser.ts diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 595b0dd113d3..9af34fd494d3 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -180,4 +180,29 @@ describe("DesktopApplicationMenu", () => { assert.equal(yield* Deferred.await(selectedAction), "zoom-in"); }), ); + + it.effect("lets the renderer own Edit clipboard accelerators", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + + for (const role of ["cut", "copy", "paste", "selectAll"] as const) { + const item: Electron.MenuItemConstructorOptions | undefined = editMenu.submenu.find( + (entry) => entry.role === role, + ); + assert.isDefined(item); + assert.equal(item.registerAccelerator, false); + } + }), + ); }); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 66244534debf..71330e38d7b5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -80,6 +80,39 @@ const checkForUpdatesFromMenu = Effect.gen(function* () { } }).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); +function desktopEditMenu(platform: NodeJS.Platform): Electron.MenuItemConstructorOptions { + // Native Edit accelerators copy the focused DOM selection. The terminal is a + // canvas plus an empty IME textarea, so those chords must reach the renderer: + // Ctrl+C is SIGINT (or copy of the Ghostty selection), Ctrl+A is + // beginning-of-line, and Cmd/Ctrl+C must not write an empty clipboard. Menu + // clicks still run the roles. + const submenu: Electron.MenuItemConstructorOptions[] = [ + { role: "undo", registerAccelerator: false }, + { role: "redo", registerAccelerator: false }, + { type: "separator" }, + { role: "cut", registerAccelerator: false }, + { role: "copy", registerAccelerator: false }, + { role: "paste", registerAccelerator: false }, + ]; + if (platform === "darwin") { + submenu.push({ role: "pasteAndMatchStyle", registerAccelerator: false }); + } + submenu.push( + { role: "delete", registerAccelerator: false }, + { role: "selectAll", registerAccelerator: false }, + ); + if (platform === "darwin") { + submenu.push( + { type: "separator" }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" }, { role: "stopSpeaking" }], + }, + ); + } + return { label: "Edit", submenu }; +} + const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; @@ -183,7 +216,7 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + desktopEditMenu(environment.platform), { label: "View", submenu: [ diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ec2e63d4146d..f43be0994528 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -34,8 +34,9 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { cn } from "~/lib/utils"; +import { openUrlInHostBrowser } from "~/lib/openUrlInHostBrowser"; import { type TerminalContextSelection } from "~/lib/terminalContext"; +import { cn } from "~/lib/utils"; import { GhosttyTerminalSurface, type GhosttyTerminalSurfaceOptions, @@ -64,9 +65,7 @@ import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; -import { previewEnvironment } from "../state/preview"; import { terminalEnvironment } from "../state/terminal"; -import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { @@ -360,9 +359,6 @@ export function TerminalViewport({ serverConfig?.availableEditors ?? [], ); const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); - const openPreview = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); const runTerminalWrite = useAtomCommand(terminalEnvironment.write, { reportFailure: false, }); @@ -750,26 +746,12 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (/^https?:\/\//u.test(text)) { - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; + // Open in this same tick so the click stays a user gesture: a later + // window.open after a preview/browser menu is popup-blocked, and + // openExternal would leave the current browser for the OS default. + if (!openUrlInHostBrowser(text)) { + writeSystemMessage(latestTerminal, "Unable to open link"); } - const fallbackToBrowser = () => { - void localApi.shell.openExternal(text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); - }); - }; - void openTerminalLinkInPreview({ - url: text, - position: { x: event.clientX, y: event.clientY }, - threadRef, - openPreview, - localApi, - fallbackToBrowser, - }); return; } const target = resolvePathLinkTarget(text, cwd); diff --git a/apps/web/src/lib/openUrlInHostBrowser.test.ts b/apps/web/src/lib/openUrlInHostBrowser.test.ts new file mode 100644 index 000000000000..add670708d63 --- /dev/null +++ b/apps/web/src/lib/openUrlInHostBrowser.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { openUrlInHostBrowser } from "./openUrlInHostBrowser"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function stubHostBrowserDocument() { + const clicks: Array<{ href: string; target: string; rel: string }> = []; + const attached: unknown[] = []; + const anchor = { + href: "", + target: "", + rel: "", + click() { + clicks.push({ href: this.href, target: this.target, rel: this.rel }); + }, + remove() { + const index = attached.indexOf(this); + if (index >= 0) attached.splice(index, 1); + }, + }; + vi.stubGlobal("document", { + createElement: (tag: string) => { + expect(tag).toBe("a"); + return anchor; + }, + body: { + append: (node: unknown) => { + attached.push(node); + }, + }, + }); + return { clicks, attached }; +} + +describe("openUrlInHostBrowser", () => { + it("clicks a same-browser _blank link during the call", () => { + const { clicks, attached } = stubHostBrowserDocument(); + + expect(openUrlInHostBrowser("https://example.com/docs?q=1#top")).toBe(true); + + expect(clicks).toEqual([ + { + href: "https://example.com/docs?q=1#top", + target: "_blank", + rel: "noopener noreferrer", + }, + ]); + expect(attached).toEqual([]); + }); + + it("refuses non-http schemes so javascript URLs cannot ride the click", () => { + const { clicks, attached } = stubHostBrowserDocument(); + + expect(openUrlInHostBrowser("javascript:alert(1)")).toBe(false); + expect(openUrlInHostBrowser("file:///etc/passwd")).toBe(false); + expect(openUrlInHostBrowser("not a url")).toBe(false); + expect(clicks).toEqual([]); + expect(attached).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/openUrlInHostBrowser.ts b/apps/web/src/lib/openUrlInHostBrowser.ts new file mode 100644 index 000000000000..d6240b8691fc --- /dev/null +++ b/apps/web/src/lib/openUrlInHostBrowser.ts @@ -0,0 +1,27 @@ +/** + * Open an http(s) URL in the browser that is already showing this page. + * + * A real `` click stays inside the user gesture, so the tab + * lands in the current browser instead of being popup-blocked or handed to a + * different OS handler after an async menu. Desktop still intercepts `_blank` + * and routes it through `openExternal`. + */ +export function openUrlInHostBrowser(url: string): boolean { + let href: string; + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + href = parsed.href; + } catch { + return false; + } + + const anchor = document.createElement("a"); + anchor.href = href; + anchor.target = "_blank"; + anchor.rel = "noopener noreferrer"; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + return true; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 9220252cb20e..a59488a1b2f1 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -66,6 +66,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -95,6 +96,47 @@ describe("LocalApi", () => { expect(dismissContextMenuMock).toHaveBeenCalledOnce(); }); + it("opens http links in the current browser without a desktop bridge", async () => { + const clicks: Array<{ href: string; target: string; rel: string }> = []; + vi.stubGlobal("document", { + createElement: (tag: string) => { + expect(tag).toBe("a"); + const anchor = { + href: "", + target: "", + rel: "", + click() { + clicks.push({ href: this.href, target: this.target, rel: this.rel }); + }, + remove() {}, + }; + return anchor; + }, + body: { append: () => undefined }, + }); + const { createLocalApi } = await import("./localApi"); + + await createLocalApi().shell.openExternal("https://example.com/path"); + + expect(clicks).toEqual([ + { href: "https://example.com/path", target: "_blank", rel: "noopener noreferrer" }, + ]); + }); + + it("refuses to open non-http links without a desktop bridge", async () => { + const createElement = vi.fn(); + vi.stubGlobal("document", { + createElement, + body: { append: () => undefined }, + }); + const { createLocalApi } = await import("./localApi"); + + await expect(createLocalApi().shell.openExternal("javascript:alert(1)")).rejects.toThrow( + "Unable to open link.", + ); + expect(createElement).not.toHaveBeenCalled(); + }); + it("uses the themed confirmation host when it is available", async () => { requestConfirmDialogMock.mockResolvedValue(true); const { createLocalApi } = await import("./localApi"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 863388106a3e..0b316e6ca7f6 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -3,6 +3,7 @@ import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/c import { requestConfirmDialog } from "./confirmDialog"; import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; +import { openUrlInHostBrowser } from "./lib/openUrlInHostBrowser"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; let cachedApi: LocalApi | undefined; @@ -28,7 +29,9 @@ function createBrowserLocalApi(): LocalApi { return; } - window.open(url, "_blank", "noopener,noreferrer"); + if (!openUrlInHostBrowser(url)) { + throw new Error("Unable to open link."); + } }, }, contextMenu: { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c11529e0c46c..aba33241c47e 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -5,12 +5,14 @@ import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, advanceTerminalSelectionClickSequence, + applyTerminalCopyEvent, ghosttyMouseButton, isTerminalAltGraphText, isTerminalCompositionCommitInput, isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, + primeTerminalCopyInput, loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, @@ -233,6 +235,60 @@ describe("isTerminalCopyShortcut", () => { }); }); +describe("primeTerminalCopyInput", () => { + it("puts the Ghostty selection into the textarea and selects it", () => { + const input = { + value: "", + selectionStart: 0, + selectionEnd: 0, + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + + primeTerminalCopyInput(input, "hello\nworld"); + + expect(input.value).toBe("hello\nworld"); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe("hello\nworld".length); + }); +}); + +describe("applyTerminalCopyEvent", () => { + it("claims the fallback only when clipboardData can take the selection", () => { + const setData = vi.fn(); + const preventDefault = vi.fn(); + + expect( + applyTerminalCopyEvent( + { + clipboardData: { setData } as unknown as DataTransfer, + preventDefault, + }, + "selected text", + ), + ).toBe("claimed"); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setData).toHaveBeenCalledExactlyOnceWith("text/plain", "selected text"); + }); + + it("leaves the writeText fallback alive when clipboardData is missing", () => { + const preventDefault = vi.fn(); + + expect( + applyTerminalCopyEvent( + { + clipboardData: null, + preventDefault, + }, + "selected text", + ), + ).toBe("deferred"); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); + describe("isTerminalPasteShortcut", () => { const event = (overrides: Partial[0]> = {}) => ({ ctrlKey: false, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9492e2d02628..424c6b0cc28e 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -336,6 +336,45 @@ export function isTerminalCopyShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; } +interface TerminalCopyInput { + value: string; + setSelectionRange(start: number, end: number): void; +} + +/** + * Park the Ghostty selection in the hidden IME textarea so a native copy + * (browser shortcut or Electron Edit → Copy) has real text instead of the + * empty field. Callers must clear this before the next IME or key encoding. + */ +export function primeTerminalCopyInput(input: TerminalCopyInput, selection: string): void { + input.value = selection; + input.setSelectionRange(0, selection.length); +} + +export function clearPrimedTerminalCopyInput(input: TerminalCopyInput): void { + if (input.value.length === 0) return; + input.value = ""; + input.setSelectionRange(0, 0); +} + +/** + * Apply a `copy` event to a Ghostty selection. Only claim the gesture when + * `clipboardData` is present and the selection was written: a macOS Edit menu + * copy often delivers a `copy` event with no data and then writes the DOM + * selection, so cancelling the `writeText` fallback there leaves the clipboard + * blank. + */ +export function applyTerminalCopyEvent( + event: Pick, + selection: string, +): "claimed" | "deferred" { + const clipboard = event.clipboardData; + if (clipboard == null) return "deferred"; + event.preventDefault(); + clipboard.setData("text/plain", selection); + return "claimed"; +} + export function isTerminalPasteShortcut( event: Pick, platform = navigator.platform, @@ -866,6 +905,7 @@ export class GhosttyTerminalSurface { } clearSelection(): void { + clearPrimedTerminalCopyInput(this.input); this.core.clearSelection(); this.selectionEnd = null; this.selectionAnchorScreen = null; @@ -935,13 +975,18 @@ export class GhosttyTerminalSurface { return; } if (isTerminalCopyShortcut(event) && this.hasSelection()) { - // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in - // onCopyEvent; not preventing the default keeps that path alive. WebKit - // omits the keyboard copy event without a DOM selection, so race the - // clipboard write against it the same way paste races its read. The - // Shift variant has no native event (Chrome binds Ctrl+Shift+C to - // inspect), so synthesize one with execCommand("copy"). + // Prime the IME textarea so a native copy of the focused field has the + // Ghostty selection instead of an empty string. A plain Ctrl+C/Cmd+C + // fires the browser's native copy event, caught in onCopyEvent; not + // preventing the default keeps that path alive. WebKit omits the + // keyboard copy event without a DOM selection, so race the clipboard + // write against it the same way paste races its read. The Shift variant + // has no native event (Chrome binds Ctrl+Shift+C to inspect), so + // synthesize one with execCommand("copy"). + const selection = this.getSelection(); + primeTerminalCopyInput(this.input, selection); if (event.shiftKey) { + this.clearSelectionAfterCopy = false; event.preventDefault(); document.execCommand("copy"); } else { @@ -950,7 +995,7 @@ export class GhosttyTerminalSurface { // Cmd+C are copy-only, so they keep the selection; resetting the flag // up front also drops any clear owed by an earlier gesture that never // completed. - this.clearSelectionAfterCopy = !event.shiftKey && !isMacPlatform(navigator.platform); + this.clearSelectionAfterCopy = !isMacPlatform(navigator.platform); const clipboard = navigator.clipboard; if (typeof clipboard?.writeText === "function") { // Defer the write past the default action: the native copy event @@ -960,7 +1005,6 @@ export class GhosttyTerminalSurface { // event already handled stops a stale resolution from clobbering a // clipboard the user filled after this copy. const token = ++this.copyShortcutToken; - const selection = this.getSelection(); void Promise.resolve().then(() => { if (this.disposed || this.copyShortcutToken !== token) return; void clipboard.writeText(selection).then( @@ -968,10 +1012,7 @@ export class GhosttyTerminalSurface { // The write may have been superseded while in flight; only // touch the selection if this gesture still owns the token. if (this.disposed || this.copyShortcutToken !== token) return; - if (this.clearSelectionAfterCopy) { - this.clearSelectionAfterCopy = false; - this.clearSelection(); - } + this.finishCopyGesture(); }, () => { // The write failed and the native event has already had its @@ -980,6 +1021,7 @@ export class GhosttyTerminalSurface { // drop it if this gesture still owns the token. if (this.copyShortcutToken === token) { this.clearSelectionAfterCopy = false; + clearPrimedTerminalCopyInput(this.input); } }, ); @@ -989,6 +1031,8 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); return; } + // Drop copy-primed text before encoding or IME so it cannot leak into the PTY. + clearPrimedTerminalCopyInput(this.input); if (isTerminalPasteShortcut(event)) { this.suppressedKeyCodes.add(event.code); const clipboard = navigator.clipboard; @@ -1074,15 +1118,24 @@ export class GhosttyTerminalSurface { private readonly onCopyEvent = (event: ClipboardEvent) => { if (!this.hasSelection()) return; - event.preventDefault(); - event.clipboardData?.setData("text/plain", this.getSelection()); + const selection = this.getSelection(); + if (applyTerminalCopyEvent(event, selection) === "deferred") { + // No clipboardData: leave the primed textarea and writeText fallback + // alive so Electron Edit → Copy can still copy real text. + return; + } // The native event beat any deferred write; drop the in-flight fallback. this.copyShortcutToken += 1; + this.finishCopyGesture(); + }; + + private finishCopyGesture(): void { + clearPrimedTerminalCopyInput(this.input); if (this.clearSelectionAfterCopy) { this.clearSelectionAfterCopy = false; this.clearSelection(); } - }; + } private readonly onPaste = (event: ClipboardEvent) => { // Always suppress the browser's default insertion: content the textarea @@ -1098,6 +1151,7 @@ export class GhosttyTerminalSurface { }; private readonly onCompositionStart = () => { + clearPrimedTerminalCopyInput(this.input); this.clearCompositionInputSuppression(); this.composing = true; }; diff --git a/docs/architecture/terminal-renderers.md b/docs/architecture/terminal-renderers.md index 4b66a13f68c4..37fbb93355bc 100644 --- a/docs/architecture/terminal-renderers.md +++ b/docs/architecture/terminal-renderers.md @@ -18,8 +18,8 @@ The platform adapters deliberately own only platform behavior. Android owns its touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input, and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available: -holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks. -React does not participate in terminal frames. +holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks +in the host browser that is already showing the app. React does not participate in terminal frames. The web runtime is singleton-scoped per browser tab so split terminals share one compiled module and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell From 074db3302e57d31e5f46e61570760bf752127431 Mon Sep 17 00:00:00 2001 From: ArjandH Date: Thu, 20 Aug 2026 18:49:47 +0200 Subject: [PATCH 2/3] fix(web): keep Edit Copy and loopback terminal previews Electron Edit Copy can fire without a prior keydown, so the deferred copy path now primes the Ghostty selection. Loopback links open the in-app preview again without an async menu; public URLs still open in the current browser on the same click. Co-authored-by: Cursor --- .../src/components/ThreadTerminalDrawer.tsx | 30 +++++-- .../preview/openTerminalLinkInPreview.test.ts | 53 ++++-------- .../preview/openTerminalLinkInPreview.ts | 80 +++++-------------- apps/web/src/terminal/ghostty/surface.test.ts | 25 ++++++ apps/web/src/terminal/ghostty/surface.ts | 6 +- 5 files changed, 91 insertions(+), 103 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index f43be0994528..6153a62b06f9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -65,8 +65,13 @@ import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; +import { previewEnvironment } from "../state/preview"; import { terminalEnvironment } from "../state/terminal"; import { useAtomCommand } from "../state/use-atom-command"; +import { + canOpenTerminalLinkInPreview, + openTerminalLinkInPreview, +} from "./preview/openTerminalLinkInPreview"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { resolveTerminalFontPreference, @@ -359,6 +364,9 @@ export function TerminalViewport({ serverConfig?.availableEditors ?? [], ); const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); + const openPreview = useAtomCommand(previewEnvironment.open, { + reportFailure: false, + }); const runTerminalWrite = useAtomCommand(terminalEnvironment.write, { reportFailure: false, }); @@ -746,12 +754,24 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (/^https?:\/\//u.test(text)) { - // Open in this same tick so the click stays a user gesture: a later - // window.open after a preview/browser menu is popup-blocked, and - // openExternal would leave the current browser for the OS default. - if (!openUrlInHostBrowser(text)) { - writeSystemMessage(latestTerminal, "Unable to open link"); + const fallbackToBrowser = () => { + if (!openUrlInHostBrowser(text)) { + writeSystemMessage(latestTerminal, "Unable to open link"); + } + }; + // Loopback URLs can open the in-app preview without a user gesture. + // Public URLs must click a _blank link in this same tick, or the + // current browser never sees them. + if (canOpenTerminalLinkInPreview(text, threadRef)) { + void openTerminalLinkInPreview({ + url: text, + threadRef, + openPreview, + fallbackToBrowser, + }); + return; } + fallbackToBrowser(); return; } const target = resolvePathLinkTarget(text, cwd); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 47f03761f6bb..3b1009d5d32d 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -1,17 +1,19 @@ -import type { LocalApi, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import type { PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + canOpenTerminalLinkInPreview, openTerminalLinkInPreview, - TerminalLinkContextMenuShowError, TerminalLinkPreviewOpenError, } from "./openTerminalLinkInPreview"; +const previewSupported = vi.hoisted(() => ({ value: true })); + vi.mock("~/previewStateStore", () => ({ applyPreviewServerSnapshot: vi.fn(), - isPreviewSupportedInRuntime: () => true, + isPreviewSupportedInRuntime: () => previewSupported.value, })); vi.mock("~/rightPanelStore", () => ({ @@ -35,44 +37,31 @@ const snapshot: PreviewSessionSnapshot = { }; afterEach(() => { + previewSupported.value = true; vi.restoreAllMocks(); }); +describe("canOpenTerminalLinkInPreview", () => { + it("keeps loopback URLs in preview and sends public URLs to the host browser", () => { + expect(canOpenTerminalLinkInPreview("http://localhost:3000/app", threadRef)).toBe(true); + expect(canOpenTerminalLinkInPreview("https://example.com/docs", threadRef)).toBe(false); + }); +}); + describe("openTerminalLinkInPreview", () => { - it("preserves context-menu failures with terminal link context before falling back", async () => { - const cause = new Error("menu unavailable"); + it("falls back without opening preview when the URL is not previewable", async () => { const fallbackToBrowser = vi.fn(); const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); - const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); await openTerminalLinkInPreview({ - url: "http://localhost:3000/path?token=secret", - position: { x: 12, y: 34 }, + url: "https://example.com/docs", threadRef, openPreview, - localApi: { - contextMenu: { - show: vi.fn(async () => { - throw cause; - }), - }, - } as unknown as LocalApi, fallbackToBrowser, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); expect(openPreview).not.toHaveBeenCalled(); - expect(reportError).toHaveBeenCalledOnce(); - const error = reportError.mock.calls[0]?.[0]; - expect(error).toBeInstanceOf(TerminalLinkContextMenuShowError); - expect(error).toMatchObject({ - environmentId: "local", - threadId: "thread-1", - targetOrigin: "http://localhost:3000", - cause, - }); - expect(error.message).not.toContain("menu unavailable"); - expect(error.targetOrigin).not.toContain("secret"); }); it("preserves the complete preview failure cause before falling back", async () => { @@ -83,14 +72,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://127.0.0.1:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(cause), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); @@ -113,14 +96,8 @@ describe("openTerminalLinkInPreview", () => { await openTerminalLinkInPreview({ url: "http://localhost:5173/", - position: { x: 12, y: 34 }, threadRef, openPreview: async () => AsyncResult.failure(Cause.interrupt()), - localApi: { - contextMenu: { - show: vi.fn(async () => "open-in-preview"), - }, - } as unknown as LocalApi, fallbackToBrowser, }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index f4e0373a73c3..3d1a5b011dc7 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -1,4 +1,4 @@ -import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { isPreviewableUrl } from "@t3tools/shared/preview"; import * as Schema from "effect/Schema"; @@ -15,15 +15,6 @@ const terminalLinkErrorContext = { cause: Schema.Defect(), }; -export class TerminalLinkContextMenuShowError extends Schema.TaggedErrorClass()( - "TerminalLinkContextMenuShowError", - terminalLinkErrorContext, -) { - override get message(): string { - return `Failed to show the context menu for terminal link ${this.targetOrigin}.`; - } -} - export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass()( "TerminalLinkPreviewOpenError", terminalLinkErrorContext, @@ -33,24 +24,24 @@ export class TerminalLinkPreviewOpenError extends Schema.TaggedErrorClass, +): boolean { + return isPreviewableUrl(url) && isPreviewSupportedInRuntime() && threadRef.threadId.length > 0; +} + interface OpenTerminalLinkInPreviewInput { readonly url: string; - readonly position: { x: number; y: number }; readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; - readonly localApi: LocalApi; readonly fallbackToBrowser: () => void; } export async function openTerminalLinkInPreview( input: OpenTerminalLinkInPreviewInput, ): Promise { - const supportsPreview = - isPreviewableUrl(input.url) && - isPreviewSupportedInRuntime() && - input.threadRef.threadId.length > 0; - - if (!supportsPreview) { + if (!canOpenTerminalLinkInPreview(input.url, input.threadRef)) { input.fallbackToBrowser(); return; } @@ -61,51 +52,24 @@ export async function openTerminalLinkInPreview( targetOrigin: new URL(input.url).origin, }; - let choice: "open-in-preview" | "open-in-browser" | null; - try { - choice = await input.localApi.contextMenu.show( - [ - { id: "open-in-preview", label: "Open in preview" }, - { id: "open-in-browser", label: "Open in browser" }, - ], - input.position, - ); - } catch (cause) { + const result = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return; + } console.error( - new TerminalLinkContextMenuShowError({ + new TerminalLinkPreviewOpenError({ ...errorContext, - cause, + cause: result.cause, }), ); input.fallbackToBrowser(); return; } - - if (choice === "open-in-preview") { - const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, - input: { threadId: input.threadRef.threadId, url: input.url }, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - console.error( - new TerminalLinkPreviewOpenError({ - ...errorContext, - cause: result.cause, - }), - ); - input.fallbackToBrowser(); - return; - } - recordVisitForThread(input.threadRef, input.url); - applyPreviewServerSnapshot(input.threadRef, result.value); - useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); - return; - } - - if (choice === "open-in-browser") { - input.fallbackToBrowser(); - } + recordVisitForThread(input.threadRef, input.url); + applyPreviewServerSnapshot(input.threadRef, result.value); + useRightPanelStore.getState().openBrowser(input.threadRef, result.value.tabId); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index aba33241c47e..ae8a0ce69ea0 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -287,6 +287,31 @@ describe("applyTerminalCopyEvent", () => { ).toBe("deferred"); expect(preventDefault).not.toHaveBeenCalled(); }); + + it("still primes the textarea when a copy event has no clipboardData", () => { + const input = { + value: "", + selectionStart: 0, + selectionEnd: 0, + setSelectionRange(start: number, end: number) { + this.selectionStart = start; + this.selectionEnd = end; + }, + }; + + expect( + applyTerminalCopyEvent( + { + clipboardData: null, + preventDefault: vi.fn(), + }, + "menu copy", + ), + ).toBe("deferred"); + primeTerminalCopyInput(input, "menu copy"); + expect(input.value).toBe("menu copy"); + expect(input.selectionEnd).toBe("menu copy".length); + }); }); describe("isTerminalPasteShortcut", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 424c6b0cc28e..2a84bc332316 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1120,8 +1120,10 @@ export class GhosttyTerminalSurface { if (!this.hasSelection()) return; const selection = this.getSelection(); if (applyTerminalCopyEvent(event, selection) === "deferred") { - // No clipboardData: leave the primed textarea and writeText fallback - // alive so Electron Edit → Copy can still copy real text. + // Edit → Copy can fire without a preceding copy keydown, so the + // textarea may still be empty. Prime it before the default action + // copies the focused field. + primeTerminalCopyInput(this.input, selection); return; } // The native event beat any deferred write; drop the in-flight fallback. From f10b1ea5df283197f2dd568cbcc26f79f88e7a84 Mon Sep 17 00:00:00 2001 From: ArjandH Date: Thu, 20 Aug 2026 19:08:42 +0200 Subject: [PATCH 3/3] fix(web): do not clear IME preedit after terminal copy Copy priming parks selection text in the Ghostty textarea. Clearing that field on every later keydown also wiped an in-progress composition, so skip the clear while IME is active. Co-authored-by: Cursor --- apps/web/src/terminal/ghostty/surface.test.ts | 12 ++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ae8a0ce69ea0..65338cb1df77 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -13,6 +13,7 @@ import { isTerminalLinkPointerGesture, isTerminalPasteShortcut, primeTerminalCopyInput, + shouldClearPrimedTerminalCopyOnKeyDown, loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, @@ -255,6 +256,17 @@ describe("primeTerminalCopyInput", () => { }); }); +describe("shouldClearPrimedTerminalCopyOnKeyDown", () => { + it("keeps the textarea intact while an IME composition is running", () => { + expect(shouldClearPrimedTerminalCopyOnKeyDown({ isComposing: true }, false)).toBe(false); + expect(shouldClearPrimedTerminalCopyOnKeyDown({ isComposing: false }, true)).toBe(false); + }); + + it("still drops primed copy text on a normal keydown", () => { + expect(shouldClearPrimedTerminalCopyOnKeyDown({ isComposing: false }, false)).toBe(true); + }); +}); + describe("applyTerminalCopyEvent", () => { it("claims the fallback only when clipboardData can take the selection", () => { const setData = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 2a84bc332316..42afd428351a 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -357,6 +357,18 @@ export function clearPrimedTerminalCopyInput(input: TerminalCopyInput): void { input.setSelectionRange(0, 0); } +/** + * Primed copy text must leave the IME textarea before the next encoded + * keydown. During composition that same field holds the preedit, so clearing + * it would abort the IME after the first composing keystroke. + */ +export function shouldClearPrimedTerminalCopyOnKeyDown( + event: Pick, + composing: boolean, +): boolean { + return !event.isComposing && !composing; +} + /** * Apply a `copy` event to a Ghostty selection. Only claim the gesture when * `clipboardData` is present and the selection was written: a macOS Edit menu @@ -1031,8 +1043,11 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.add(event.code); return; } - // Drop copy-primed text before encoding or IME so it cannot leak into the PTY. - clearPrimedTerminalCopyInput(this.input); + // Drop copy-primed text before encoding so it cannot leak into the PTY. + // Skip while composing: the textarea holds the IME preedit. + if (shouldClearPrimedTerminalCopyOnKeyDown(event, this.composing)) { + clearPrimedTerminalCopyInput(this.input); + } if (isTerminalPasteShortcut(event)) { this.suppressedKeyCodes.add(event.code); const clipboard = navigator.clipboard;