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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 10:34:04 -07:00
parent a2d6349bb8
commit b0208c140a
6 changed files with 132 additions and 19 deletions

View File

@@ -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.

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(<SessionTerminal sessionId="s1" />);
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", {

View File

@@ -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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);