diff --git a/frontend/src/renderer/components/XtermTerminal.test.tsx b/frontend/src/renderer/components/XtermTerminal.test.tsx index d3f0b0d32..efaa057f5 100644 --- a/frontend/src/renderer/components/XtermTerminal.test.tsx +++ b/frontend/src/renderer/components/XtermTerminal.test.tsx @@ -114,10 +114,23 @@ vi.mock("@xterm/addon-webgl", () => ({ }, })); +function setNavigatorPlatform(platform: string) { + Object.defineProperty(window.navigator, "platform", { + configurable: true, + value: platform, + }); + Object.defineProperty(window.navigator, "userAgentData", { + configurable: true, + value: { platform }, + }); +} + describe("XtermTerminal", () => { beforeEach(() => { state.lastTerminal = null; + setNavigatorPlatform("Linux x86_64"); window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue(""); }); it("copies selected terminal text on the terminal copy shortcut", () => { @@ -130,6 +143,7 @@ describe("XtermTerminal", () => { ctrlKey: false, shiftKey: false, preventDefault: vi.fn(), + stopPropagation: vi.fn(), } as unknown as KeyboardEvent; const allowed = state.lastTerminal!.keyHandler!(event); @@ -186,6 +200,7 @@ describe("XtermTerminal", () => { ctrlKey: false, shiftKey: false, preventDefault: vi.fn(), + stopPropagation: vi.fn(), } as unknown as KeyboardEvent; const allowed = state.lastTerminal!.keyHandler!(event); @@ -194,6 +209,159 @@ describe("XtermTerminal", () => { expect(writeText).toHaveBeenLastCalledWith("retry me"); }); + it("leaves plain Ctrl+C as terminal input on non-Windows even when text is selected", () => { + render(); + state.lastTerminal!.selection = "selected text"; + + const event = { + key: "c", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + + expect(allowed).toBe(true); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled(); + }); + + it("copies selected text with plain Ctrl+C on Windows", () => { + setNavigatorPlatform("Win32"); + render(); + state.lastTerminal!.selection = "windows copy"; + + const event = { + key: "c", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + + expect(allowed).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("windows copy"); + }); + + it("leaves plain Ctrl+C as terminal input on Windows when nothing is selected", () => { + setNavigatorPlatform("Win32"); + render(); + state.lastTerminal!.selection = ""; + + const event = { + key: "c", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + + expect(allowed).toBe(true); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled(); + }); + + it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => { + const onInput = vi.fn(); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld"); + render( terminal.onUserInput(onInput)} />); + + const event = { + key: "v", + metaKey: false, + ctrlKey: true, + shiftKey: true, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + await Promise.resolve(); + + expect(allowed).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(window.ao!.clipboard.readText).toHaveBeenCalled(); + expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste"); + }); + + it("supports classic Windows terminal copy and paste shortcuts", async () => { + const onInput = vi.fn(); + window.ao!.clipboard.readText = vi.fn().mockResolvedValue("insert paste"); + render( terminal.onUserInput(onInput)} />); + state.lastTerminal!.selection = "insert copy"; + + const copyEvent = { + key: "Insert", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + expect(state.lastTerminal!.keyHandler!(copyEvent)).toBe(false); + expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("insert copy"); + + const pasteEvent = { + key: "Insert", + metaKey: false, + ctrlKey: false, + shiftKey: true, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + expect(state.lastTerminal!.keyHandler!(pasteEvent)).toBe(false); + await Promise.resolve(); + + expect(window.ao!.clipboard.readText).toHaveBeenCalled(); + expect(onInput).toHaveBeenCalledWith("insert paste", "paste"); + }); + + it.each([ + ["Option/Alt+Left", { key: "ArrowLeft", altKey: true }, "\x1bb"], + ["Option/Alt+Right", { key: "ArrowRight", altKey: true }, "\x1bf"], + ["Option/Alt+Backspace", { key: "Backspace", altKey: true }, "\x1b\x7f"], + ["Option/Alt+Delete", { key: "Delete", altKey: true }, "\x1bd"], + ["Ctrl+Left", { key: "ArrowLeft", ctrlKey: true }, "\x1b[1;5D"], + ["Ctrl+Right", { key: "ArrowRight", ctrlKey: true }, "\x1b[1;5C"], + ["Ctrl+Backspace", { key: "Backspace", ctrlKey: true }, "\x1b\x7f"], + ["Ctrl+Delete", { key: "Delete", ctrlKey: true }, "\x1bd"], + ])("normalizes %s into terminal input", (_name, init, expected) => { + const onInput = vi.fn(); + render( terminal.onUserInput(onInput)} />); + + const event = { + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...init, + } as unknown as KeyboardEvent; + const allowed = state.lastTerminal!.keyHandler!(event); + + expect(allowed).toBe(false); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(onInput).toHaveBeenCalledWith(expected, "terminal"); + }); + it("forwards generated xterm input data such as wheel scroll reports", () => { const onInput = vi.fn(); render( terminal.onUserInput(onInput)} />); diff --git a/frontend/src/renderer/components/XtermTerminal.tsx b/frontend/src/renderer/components/XtermTerminal.tsx index 9f354f588..11661e3de 100644 --- a/frontend/src/renderer/components/XtermTerminal.tsx +++ b/frontend/src/renderer/components/XtermTerminal.tsx @@ -85,11 +85,67 @@ function bracketPastedText(text: string, bracketedPasteMode: boolean): string { } function isTerminalCopyShortcut(event: KeyboardEvent): boolean { + if (event.key === "Insert") return event.ctrlKey && !event.altKey && !event.metaKey; if (event.key.toLowerCase() !== "c") return false; if (event.metaKey) return true; + if (event.ctrlKey && event.shiftKey && !event.altKey) return true; + return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey; +} + +function isWindowsPlatform(): boolean { + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform; + return platform.toLowerCase().startsWith("win"); +} + +function isTerminalPasteShortcut(event: KeyboardEvent): boolean { + if (event.key === "Insert") return event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey; + if (event.key.toLowerCase() !== "v") return false; + if (event.metaKey) return true; return event.ctrlKey && event.shiftKey; } +function consumeTerminalShortcut(event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); +} + +function normalizedTerminalShortcut(event: KeyboardEvent): string | null { + if (event.metaKey || event.shiftKey) return null; + + if (event.altKey && !event.ctrlKey) { + switch (event.key) { + case "ArrowLeft": + return "\x1bb"; + case "ArrowRight": + return "\x1bf"; + case "Backspace": + return "\x1b\x7f"; + case "Delete": + return "\x1bd"; + default: + return null; + } + } + + if (event.ctrlKey && !event.altKey) { + switch (event.key) { + case "ArrowLeft": + return "\x1b[1;5D"; + case "ArrowRight": + return "\x1b[1;5C"; + case "Backspace": + return "\x1b\x7f"; + case "Delete": + return "\x1bd"; + default: + return null; + } + } + + return null; +} + function terminalHasFocus(host: HTMLElement): boolean { const activeElement = document.activeElement; return !!activeElement && host.contains(activeElement); @@ -232,9 +288,45 @@ export function XtermTerminal(props: XtermTerminalProps) { const clearCopiedSelection = () => { lastCopiedSelection = ""; }; + const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>(); + const emitUserInput = (data: string, source: TerminalUserInputSource) => { + if (data.length === 0) return; + userInputListeners.forEach((listener) => listener(data, source)); + }; + const pasteText = (text: string) => { + const prepared = preparePastedText(text); + const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true; + emitUserInput(bracketPastedText(prepared, bracketed), "paste"); + }; + const pasteFromClipboard = () => { + void aoBridge.clipboard + .readText() + .then(pasteText) + .catch((error) => { + console.warn("Unable to paste terminal clipboard text", error); + }); + }; term.attachCustomKeyEventHandler((event) => { - if (!isTerminalCopyShortcut(event) || !copySelection()) return true; - event.preventDefault(); + if (isTerminalCopyShortcut(event)) { + if (copySelection()) { + consumeTerminalShortcut(event); + return false; + } + if ((event.ctrlKey && event.shiftKey) || (event.key === "Insert" && event.ctrlKey)) { + consumeTerminalShortcut(event); + return false; + } + return true; + } + if (isTerminalPasteShortcut(event)) { + consumeTerminalShortcut(event); + pasteFromClipboard(); + return false; + } + const normalized = normalizedTerminalShortcut(event); + if (!normalized) return true; + consumeTerminalShortcut(event); + emitUserInput(normalized, "terminal"); return false; }); const copyInput = (event: ClipboardEvent) => { @@ -338,11 +430,6 @@ export function XtermTerminal(props: XtermTerminalProps) { // misses them. Listen on window directly as a session-long recovery path. window.addEventListener("resize", fitTerminal); - const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>(); - const emitUserInput = (data: string, source: TerminalUserInputSource) => { - if (data.length === 0) return; - userInputListeners.forEach((listener) => listener(data, source)); - }; const terminalInput = term.onData((data) => emitUserInput(data, "terminal")); // Translate wheel motion into SGR wheel reports for zellij (see @@ -377,9 +464,7 @@ export function XtermTerminal(props: XtermTerminalProps) { event.preventDefault(); event.stopPropagation(); const text = event.clipboardData?.getData("text/plain") ?? ""; - const prepared = preparePastedText(text); - const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true; - emitUserInput(bracketPastedText(prepared, bracketed), "paste"); + pasteText(text); }; const compositionInput = (event: CompositionEvent) => { emitUserInput(event.data, "composition");