FN-5885: fix terminal copy/paste shortcuts

Restore expected terminal clipboard shortcuts.

- intercept Ctrl/Cmd+C in the dashboard terminal to copy the current selection while preserving plain SIGINT when nothing is selected
- intercept Ctrl/Cmd+V to read clipboard text and send it into the active PTY session
- add terminal shortcut regression coverage and document the new integrated terminal behavior
- add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/friendly-ravens-hammer.md               |   5 +
 docs/dashboard-guide.md                            |   2 +
 packages/dashboard/app/components/TerminalModal.tsx     |  48 +++++++++
 packages/dashboard/app/components/__tests__/TerminalModal.test.tsx    | 113 +++++++++++++++++++++
 4 files changed, 168 insertions(+)

Fusion-Task-Id: FN-5885

Fusion-Task-Lineage: f8b3658c-2b52-4094-8cef-79c762f4d78d
This commit is contained in:
gsxdsm
2026-06-02 09:20:31 -07:00
parent 1a065f2637
commit fa68edf9fa
4 changed files with 168 additions and 0 deletions

View File

@@ -161,6 +161,16 @@ function isMobileDevice(): boolean {
return hasTouchScreen && isNarrow;
}
function isMacPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
}
const platform = navigator.platform ?? "";
const userAgent = navigator.userAgent ?? "";
return /mac/i.test(platform) || /mac/i.test(userAgent);
}
/**
* Compute how many CSS pixels the virtual keyboard covers from the bottom
* of the layout viewport. Returns 0 on desktop or when visualViewport is
@@ -649,6 +659,44 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
sendInputRef.current(data);
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown") {
return true;
}
const isModifierPressed = isMacPlatform() ? event.metaKey : event.ctrlKey;
if (!isModifierPressed || event.altKey || event.shiftKey) {
return true;
}
const key = event.key.toLowerCase();
if (key === "c") {
const selection = terminal.hasSelection() ? terminal.getSelection() : "";
if (!selection) {
return true;
}
navigator.clipboard?.writeText(selection).catch(() => {
// Ignore clipboard permission/errors so terminal input stays responsive.
});
return false;
}
if (key === "v") {
navigator.clipboard?.readText().then((text) => {
if (text) {
sendInputRef.current(text);
}
}).catch(() => {
// Ignore clipboard permission/errors so terminal input stays responsive.
});
return false;
}
return true;
});
// Window resize listener bound to this xterm. Tracked in a ref so it
// can be removed when xterm is disposed (modal close, tab switch).
const resizeHandler = () => {

View File

@@ -23,10 +23,18 @@ vi.mock("../../api", () => ({
// Mock xterm modules to prevent DOM errors in jsdom
const mockFitAddonFit = vi.fn();
let terminalKeyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockTerminalInstance = {
loadAddon: vi.fn(),
open: vi.fn(),
onData: vi.fn((_cb: (data: string) => void) => ({ dispose: vi.fn() })),
attachCustomKeyEventHandler: vi.fn((handler: (event: KeyboardEvent) => boolean) => {
terminalKeyEventHandler = handler;
}),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ""),
paste: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
clear: vi.fn(),
@@ -122,7 +130,18 @@ describe("TerminalModal", () => {
beforeEach(() => {
vi.clearAllMocks();
terminalKeyEventHandler = null;
mockFitAddonFit.mockClear();
mockTerminalInstance.hasSelection.mockReturnValue(false);
mockTerminalInstance.getSelection.mockReturnValue("");
Object.defineProperty(navigator, "platform", {
value: "Win32",
configurable: true,
});
Object.defineProperty(navigator, "clipboard", {
value: undefined,
configurable: true,
});
window.localStorage.removeItem(TERMINAL_FONT_SIZE_KEY);
mockTerminalInstance.options.fontSize = 14;
mockCreateTerminalSession.mockResolvedValue({
@@ -3993,6 +4012,100 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
expect(mockSendInput).toHaveBeenCalledWith("echo hello\r");
});
it("copies selected terminal text on ctrl+c and blocks sigint", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "platform", {
value: "Win32",
configurable: true,
});
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
mockTerminalInstance.hasSelection.mockReturnValue(true);
mockTerminalInstance.getSelection.mockReturnValue("copied output");
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(terminalKeyEventHandler).not.toBeNull();
});
const handled = terminalKeyEventHandler?.(
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
);
expect(handled).toBe(false);
expect(writeText).toHaveBeenCalledWith("copied output");
expect(mockSendInput).not.toHaveBeenCalled();
});
it("preserves sigint on ctrl+c when nothing is selected", async () => {
Object.defineProperty(navigator, "platform", {
value: "Win32",
configurable: true,
});
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
mockTerminalInstance.hasSelection.mockReturnValue(false);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(terminalKeyEventHandler).not.toBeNull();
});
const handled = terminalKeyEventHandler?.(
new KeyboardEvent("keydown", { key: "c", ctrlKey: true }),
);
expect(handled).toBe(true);
});
it("pastes clipboard text into the active session on cmd+v", async () => {
const readText = vi.fn().mockResolvedValue("npm test\n");
Object.defineProperty(navigator, "platform", {
value: "MacIntel",
configurable: true,
});
Object.defineProperty(navigator, "clipboard", {
value: { readText },
configurable: true,
});
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(terminalKeyEventHandler).not.toBeNull();
});
const handled = terminalKeyEventHandler?.(
new KeyboardEvent("keydown", { key: "v", metaKey: true }),
);
expect(handled).toBe(false);
await waitFor(() => {
expect(readText).toHaveBeenCalled();
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
});
});
it("leaves unrelated key handling untouched", async () => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(terminalKeyEventHandler).not.toBeNull();
});
const handled = terminalKeyEventHandler?.(
new KeyboardEvent("keydown", { key: "x", ctrlKey: true }),
);
expect(handled).toBe(true);
});
it("keeps terminal input forwarding active after active-tab title rerenders", async () => {
let terminalInputCallback: ((data: string) => void) | null = null;
const disposeInputHandler = vi.fn();