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();