From b0208c140a8983b68f7d2dd914a9acb0670393a8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 10:34:04 -0700 Subject: [PATCH] FN-7500: restore terminal clipboard shortcuts Restore terminal copy and paste shortcut handling across integrated and embedded sessions. - Handle Ctrl/Cmd+V by reading clipboard text and forwarding it once to the active PTY or attach channel. - Preserve Ctrl/Cmd+C selection copy behavior while leaving no-selection interrupts available to the shell. - Document the exact-once terminal paste behavior and add a patch changeset. - Cover successful, missing, rejected, and empty clipboard paste paths in terminal tests. Files changed: .changeset/fn-7500-terminal-shortcuts.md | 7 +++ docs/dashboard-guide.md | 4 +- .../dashboard/app/components/SessionTerminal.tsx | 24 ++++++++++- .../dashboard/app/components/TerminalModal.tsx | 23 ++++++++- .../components/__tests__/SessionTerminal.test.tsx | 43 ++++++++++++++++- .../components/__tests__/TerminalModal.test.tsx | 50 +++++++++++++++++++- 6 files changed, 132 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7500 Fusion-Task-Lineage: 8e87fa22-c0b7-402d-aeef-322cbd852529 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7500-terminal-shortcuts.md | 7 +++ docs/dashboard-guide.md | 4 +- .../app/components/SessionTerminal.tsx | 24 ++++++++- .../app/components/TerminalModal.tsx | 23 +++++++-- .../__tests__/SessionTerminal.test.tsx | 43 ++++++++++++++-- .../__tests__/TerminalModal.test.tsx | 50 ++++++++++++++++--- 6 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 .changeset/fn-7500-terminal-shortcuts.md diff --git a/.changeset/fn-7500-terminal-shortcuts.md b/.changeset/fn-7500-terminal-shortcuts.md new file mode 100644 index 0000000000..e296300ad5 --- /dev/null +++ b/.changeset/fn-7500-terminal-shortcuts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore terminal Ctrl/Cmd copy and paste shortcuts. +category: fix +dev: Integrated and embedded terminals now own physical clipboard paste to avoid swallowed or duplicate input. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e9e3fd5335..95e9fbd375 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -564,12 +564,12 @@ Features: - Multiple terminal tabs, including Project Root tabs and task-worktree tabs - PTY-backed shell sessions - Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT -- Ctrl/Cmd+V pastes clipboard text into the active terminal session +- Ctrl/Cmd+V pastes clipboard text exactly once into the active integrated terminal or live embedded CLI session - The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, and Up/Down/Left/Right arrow buttons that send standard ANSI cursor sequences for keyboard-less shell history and line editing - Shortcuts panel buttons preserve terminal focus on the active terminal session during pointer, mouse, and touch activation, so Ctrl combinations reliably emit control bytes to the shell - The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically - Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts -- Embedded CLI session terminals honor the same saved preferences and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and paste travels once through xterm's native input path. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports. +- Embedded CLI session terminals honor the same saved preferences and physical copy/paste semantics for live interactive session views: selected text copies with the platform copy modifier, no-selection Ctrl+C stays available to the shell, and Ctrl/Cmd+V sends clipboard text exactly once to the attach channel. Idle, ended, and read-only replay views suppress input handlers and mobile accessory controls. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports. - Mobile-aware virtual keyboard handling and auto-refit behavior - Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required) diff --git a/packages/dashboard/app/components/SessionTerminal.tsx b/packages/dashboard/app/components/SessionTerminal.tsx index 6c109bb0cb..49d4068085 100644 --- a/packages/dashboard/app/components/SessionTerminal.tsx +++ b/packages/dashboard/app/components/SessionTerminal.tsx @@ -513,7 +513,10 @@ export function SessionTerminal({ /* FNXC:Terminal 2026-06-30-00:10: - FN-7262 root cause: the embedded SessionTerminal attach surface forwarded raw xterm data but never installed the copy/paste key filter already used by TerminalModal, so physical Ctrl/Cmd+C with a selection could be swallowed by xterm/browser routing inconsistently while replay states still accepted input. Register exactly one handler with the xterm instance for live writable sessions: platform copy+C copies selected text, copy+C without selection stays on the PTY/SIGINT path, and paste is left to xterm's native onData flow so it is delivered once. + FN-7262 root cause: the embedded SessionTerminal attach surface forwarded raw xterm data but never installed the copy/paste key filter already used by TerminalModal, so physical Ctrl/Cmd+C with a selection could be swallowed by xterm/browser routing inconsistently while replay states still accepted input. Register exactly one handler with the xterm instance for live writable sessions: platform copy+C copies selected text, copy+C without selection stays on the PTY/SIGINT path, and replay states never receive input hooks. + + FNXC:Terminal 2026-07-04-10:25: + GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches embedded CLI attach channels. Own platform paste here, then return false so the browser/xterm native paste path cannot also emit a duplicate WebSocket input frame. */ if (ticketCanAcceptInput) { term.onData((data: string) => { @@ -546,7 +549,24 @@ export function SessionTerminal({ } if (key === "v") { - return true; + const readText = navigator.clipboard?.readText; + if (!readText) { + return false; + } + readText.call(navigator.clipboard) + .then((text) => { + if (!text) { + return; + } + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "input", data: text })); + } + }) + .catch(() => { + // Ignore clipboard permission/errors so terminal input stays responsive. + }); + return false; } return true; diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 9bf5e90fbe..a9643efb1d 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -1500,10 +1500,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG } if (key === "v") { - // Let xterm's helper textarea handle paste natively. Reading the - // clipboard here and also allowing the browser paste path causes - // duplicate PTY input on Cmd/Ctrl+V. - return true; + /* + FNXC:Terminal 2026-07-04-10:24: + GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so the browser/xterm native paste path cannot also emit duplicate input. + */ + const readText = navigator.clipboard?.readText; + if (!readText) { + return false; + } + readText.call(navigator.clipboard) + .then((text) => { + if (!text || xtermInitializedRef.current !== currentSessionId) { + return; + } + sendInputRef.current(text); + }) + .catch(() => { + // Ignore clipboard permission/errors so terminal input stays responsive. + }); + return false; } return true; diff --git a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx index 613a7f16f8..4aaa157941 100644 --- a/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx @@ -214,7 +214,7 @@ describe("SessionTerminal", () => { ["non-mac", "Win32", { ctrlKey: true }], ] as const)("preserves physical copy/paste terminal semantics on %s", async (_name, platform, modifier) => { const writeText = vi.fn().mockResolvedValue(undefined); - const readText = vi.fn().mockResolvedValue("ignored because xterm handles paste"); + const readText = vi.fn().mockResolvedValue("pasted once"); Object.defineProperty(navigator, "platform", { value: platform, configurable: true, @@ -237,15 +237,48 @@ describe("SessionTerminal", () => { expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(true); const beforePasteFrames = FakeWS.instances[0].sent.length; - expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(true); - expect(readText).not.toHaveBeenCalled(); - const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined; - inputHandler?.("pasted once"); + expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(false); + await waitFor(() => expect(readText).toHaveBeenCalledTimes(1)); expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([ JSON.stringify({ type: "input", data: "pasted once" }), ]); }); + it.each([ + ["missing clipboard", undefined], + ["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }], + ["empty clipboard", { readText: vi.fn().mockResolvedValue("") }], + ] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => { + Object.defineProperty(navigator, "platform", { + value: "Win32", + configurable: true, + }); + Object.defineProperty(navigator, "clipboard", { + value: clipboard, + configurable: true, + }); + + render(); + await waitFor(() => expect(FakeWS.instances.length).toBe(1)); + await waitFor(() => expect(sessionKeyEventHandler).not.toBeNull()); + + const beforePasteFrames = FakeWS.instances[0].sent.length; + expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false); + if (clipboard?.readText) { + await waitFor(() => expect(clipboard.readText).toHaveBeenCalledTimes(1)); + } + await act(async () => { + await Promise.resolve(); + }); + expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([]); + + const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined; + inputHandler?.("typed input"); + expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([ + JSON.stringify({ type: "input", data: "typed input" }), + ]); + }); + it("refits after font settlement even when iOS rejects the font-load shorthand", async () => { const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand"))); Object.defineProperty(document, "fonts", { diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index e0163caed6..0cd040e363 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -6171,7 +6171,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => { ["mac", "MacIntel", { metaKey: true }], ["non-mac", "Win32", { ctrlKey: true }], ] as const)( - "delivers keyboard paste exactly once via xterm native paste on %s", + "delivers physical keyboard paste exactly once from clipboard on %s", async (_name, platform, modifier) => { const readText = vi.fn().mockResolvedValue("npm test\n"); Object.defineProperty(navigator, "platform", { @@ -6193,17 +6193,55 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => { const handled = terminalKeyEventHandler?.( new KeyboardEvent("keydown", { key: "v", ...modifier }), ); - act(() => { - terminalDataHandler?.("npm test\n"); - }); - expect(handled).toBe(true); - expect(readText).not.toHaveBeenCalled(); + expect(handled).toBe(false); + await waitFor(() => expect(readText).toHaveBeenCalledTimes(1)); expect(mockSendInput).toHaveBeenCalledTimes(1); expect(mockSendInput).toHaveBeenCalledWith("npm test\n"); }, ); + it.each([ + ["missing clipboard", undefined], + ["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }], + ["empty clipboard", { readText: vi.fn().mockResolvedValue("") }], + ] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => { + Object.defineProperty(navigator, "platform", { + value: "Win32", + configurable: true, + }); + Object.defineProperty(navigator, "clipboard", { + value: clipboard, + configurable: true, + }); + + render(); + + await waitFor(() => { + expect(terminalKeyEventHandler).not.toBeNull(); + expect(terminalDataHandler).not.toBeNull(); + }); + + const handled = terminalKeyEventHandler?.( + new KeyboardEvent("keydown", { key: "v", ctrlKey: true }), + ); + + expect(handled).toBe(false); + if (clipboard?.readText) { + await waitFor(() => expect(clipboard.readText).toHaveBeenCalledTimes(1)); + } + await act(async () => { + await Promise.resolve(); + }); + expect(mockSendInput).not.toHaveBeenCalled(); + + act(() => { + terminalDataHandler?.("typed input"); + }); + expect(mockSendInput).toHaveBeenCalledTimes(1); + expect(mockSendInput).toHaveBeenCalledWith("typed input"); + }); + it("delivers native helper-textarea paste exactly once without the shortcut handler", async () => { render();