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