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:
7
.changeset/fn-7500-terminal-shortcuts.md
Normal file
7
.changeset/fn-7500-terminal-shortcuts.md
Normal 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.
|
||||||
@@ -564,12 +564,12 @@ Features:
|
|||||||
- Multiple terminal tabs, including Project Root tabs and task-worktree tabs
|
- Multiple terminal tabs, including Project Root tabs and task-worktree tabs
|
||||||
- PTY-backed shell sessions
|
- PTY-backed shell sessions
|
||||||
- Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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)
|
- Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required)
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,10 @@ export function SessionTerminal({
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:Terminal 2026-06-30-00:10:
|
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) {
|
if (ticketCanAcceptInput) {
|
||||||
term.onData((data: string) => {
|
term.onData((data: string) => {
|
||||||
@@ -546,7 +549,24 @@ export function SessionTerminal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === "v") {
|
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;
|
return true;
|
||||||
|
|||||||
@@ -1500,10 +1500,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === "v") {
|
if (key === "v") {
|
||||||
// Let xterm's helper textarea handle paste natively. Reading the
|
/*
|
||||||
// clipboard here and also allowing the browser paste path causes
|
FNXC:Terminal 2026-07-04-10:24:
|
||||||
// duplicate PTY input on Cmd/Ctrl+V.
|
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.
|
||||||
return true;
|
*/
|
||||||
|
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;
|
return true;
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ describe("SessionTerminal", () => {
|
|||||||
["non-mac", "Win32", { ctrlKey: true }],
|
["non-mac", "Win32", { ctrlKey: true }],
|
||||||
] as const)("preserves physical copy/paste terminal semantics on %s", async (_name, platform, modifier) => {
|
] as const)("preserves physical copy/paste terminal semantics on %s", async (_name, platform, modifier) => {
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
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", {
|
Object.defineProperty(navigator, "platform", {
|
||||||
value: platform,
|
value: platform,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -237,15 +237,48 @@ describe("SessionTerminal", () => {
|
|||||||
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(true);
|
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "c", ...modifier }))).toBe(true);
|
||||||
|
|
||||||
const beforePasteFrames = FakeWS.instances[0].sent.length;
|
const beforePasteFrames = FakeWS.instances[0].sent.length;
|
||||||
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(true);
|
expect(sessionKeyEventHandler?.(new KeyboardEvent("keydown", { key: "v", ...modifier }))).toBe(false);
|
||||||
expect(readText).not.toHaveBeenCalled();
|
await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
|
||||||
const inputHandler = mockTerm.onData.mock.calls[0]?.[0] as ((data: string) => void) | undefined;
|
|
||||||
inputHandler?.("pasted once");
|
|
||||||
expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([
|
expect(FakeWS.instances[0].sent.slice(beforePasteFrames)).toEqual([
|
||||||
JSON.stringify({ type: "input", data: "pasted once" }),
|
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 () => {
|
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")));
|
const load = vi.fn(() => Promise.reject(new DOMException("Invalid font shorthand")));
|
||||||
Object.defineProperty(document, "fonts", {
|
Object.defineProperty(document, "fonts", {
|
||||||
|
|||||||
@@ -6171,7 +6171,7 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
|||||||
["mac", "MacIntel", { metaKey: true }],
|
["mac", "MacIntel", { metaKey: true }],
|
||||||
["non-mac", "Win32", { ctrlKey: true }],
|
["non-mac", "Win32", { ctrlKey: true }],
|
||||||
] as const)(
|
] 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) => {
|
async (_name, platform, modifier) => {
|
||||||
const readText = vi.fn().mockResolvedValue("npm test\n");
|
const readText = vi.fn().mockResolvedValue("npm test\n");
|
||||||
Object.defineProperty(navigator, "platform", {
|
Object.defineProperty(navigator, "platform", {
|
||||||
@@ -6193,17 +6193,55 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
|||||||
const handled = terminalKeyEventHandler?.(
|
const handled = terminalKeyEventHandler?.(
|
||||||
new KeyboardEvent("keydown", { key: "v", ...modifier }),
|
new KeyboardEvent("keydown", { key: "v", ...modifier }),
|
||||||
);
|
);
|
||||||
act(() => {
|
|
||||||
terminalDataHandler?.("npm test\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(false);
|
||||||
expect(readText).not.toHaveBeenCalled();
|
await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
|
||||||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||||||
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
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 () => {
|
it("delivers native helper-textarea paste exactly once without the shortcut handler", async () => {
|
||||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user