fix: normalize terminal keyboard shortcuts (#381)

* fix(frontend): normalize terminal shortcuts

* fix(frontend): preserve ctrl-c interrupt outside windows
This commit is contained in:
Laxman 2026-06-22 19:14:53 +05:30 committed by GitHub
parent 8fa403c480
commit 4c8f92ad39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 263 additions and 10 deletions

View File

@ -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(<XtermTerminal theme="dark" />);
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(<XtermTerminal theme="dark" />);
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(<XtermTerminal theme="dark" />);
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(<XtermTerminal theme="dark" onReady={(terminal) => 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(<XtermTerminal theme="dark" onReady={(terminal) => 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(<XtermTerminal theme="dark" onReady={(terminal) => 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(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);

View File

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