Skip to content

Commit 3037610

Browse files
author
Vaibhaav
committed
2 parents 86f30f0 + 4c8f92a commit 3037610

2 files changed

Lines changed: 263 additions & 10 deletions

File tree

frontend/src/renderer/components/XtermTerminal.test.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,23 @@ vi.mock("@xterm/addon-webgl", () => ({
114114
},
115115
}));
116116

117+
function setNavigatorPlatform(platform: string) {
118+
Object.defineProperty(window.navigator, "platform", {
119+
configurable: true,
120+
value: platform,
121+
});
122+
Object.defineProperty(window.navigator, "userAgentData", {
123+
configurable: true,
124+
value: { platform },
125+
});
126+
}
127+
117128
describe("XtermTerminal", () => {
118129
beforeEach(() => {
119130
state.lastTerminal = null;
131+
setNavigatorPlatform("Linux x86_64");
120132
window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined);
133+
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("");
121134
});
122135

123136
it("copies selected terminal text on the terminal copy shortcut", () => {
@@ -130,6 +143,7 @@ describe("XtermTerminal", () => {
130143
ctrlKey: false,
131144
shiftKey: false,
132145
preventDefault: vi.fn(),
146+
stopPropagation: vi.fn(),
133147
} as unknown as KeyboardEvent;
134148
const allowed = state.lastTerminal!.keyHandler!(event);
135149

@@ -186,6 +200,7 @@ describe("XtermTerminal", () => {
186200
ctrlKey: false,
187201
shiftKey: false,
188202
preventDefault: vi.fn(),
203+
stopPropagation: vi.fn(),
189204
} as unknown as KeyboardEvent;
190205
const allowed = state.lastTerminal!.keyHandler!(event);
191206

@@ -194,6 +209,159 @@ describe("XtermTerminal", () => {
194209
expect(writeText).toHaveBeenLastCalledWith("retry me");
195210
});
196211

212+
it("leaves plain Ctrl+C as terminal input on non-Windows even when text is selected", () => {
213+
render(<XtermTerminal theme="dark" />);
214+
state.lastTerminal!.selection = "selected text";
215+
216+
const event = {
217+
key: "c",
218+
metaKey: false,
219+
ctrlKey: true,
220+
shiftKey: false,
221+
altKey: false,
222+
preventDefault: vi.fn(),
223+
stopPropagation: vi.fn(),
224+
} as unknown as KeyboardEvent;
225+
const allowed = state.lastTerminal!.keyHandler!(event);
226+
227+
expect(allowed).toBe(true);
228+
expect(event.preventDefault).not.toHaveBeenCalled();
229+
expect(event.stopPropagation).not.toHaveBeenCalled();
230+
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
231+
});
232+
233+
it("copies selected text with plain Ctrl+C on Windows", () => {
234+
setNavigatorPlatform("Win32");
235+
render(<XtermTerminal theme="dark" />);
236+
state.lastTerminal!.selection = "windows copy";
237+
238+
const event = {
239+
key: "c",
240+
metaKey: false,
241+
ctrlKey: true,
242+
shiftKey: false,
243+
altKey: false,
244+
preventDefault: vi.fn(),
245+
stopPropagation: vi.fn(),
246+
} as unknown as KeyboardEvent;
247+
const allowed = state.lastTerminal!.keyHandler!(event);
248+
249+
expect(allowed).toBe(false);
250+
expect(event.preventDefault).toHaveBeenCalled();
251+
expect(event.stopPropagation).toHaveBeenCalled();
252+
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("windows copy");
253+
});
254+
255+
it("leaves plain Ctrl+C as terminal input on Windows when nothing is selected", () => {
256+
setNavigatorPlatform("Win32");
257+
render(<XtermTerminal theme="dark" />);
258+
state.lastTerminal!.selection = "";
259+
260+
const event = {
261+
key: "c",
262+
metaKey: false,
263+
ctrlKey: true,
264+
shiftKey: false,
265+
altKey: false,
266+
preventDefault: vi.fn(),
267+
stopPropagation: vi.fn(),
268+
} as unknown as KeyboardEvent;
269+
const allowed = state.lastTerminal!.keyHandler!(event);
270+
271+
expect(allowed).toBe(true);
272+
expect(event.preventDefault).not.toHaveBeenCalled();
273+
expect(event.stopPropagation).not.toHaveBeenCalled();
274+
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
275+
});
276+
277+
it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => {
278+
const onInput = vi.fn();
279+
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
280+
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
281+
282+
const event = {
283+
key: "v",
284+
metaKey: false,
285+
ctrlKey: true,
286+
shiftKey: true,
287+
altKey: false,
288+
preventDefault: vi.fn(),
289+
stopPropagation: vi.fn(),
290+
} as unknown as KeyboardEvent;
291+
const allowed = state.lastTerminal!.keyHandler!(event);
292+
await Promise.resolve();
293+
294+
expect(allowed).toBe(false);
295+
expect(event.preventDefault).toHaveBeenCalled();
296+
expect(event.stopPropagation).toHaveBeenCalled();
297+
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
298+
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
299+
});
300+
301+
it("supports classic Windows terminal copy and paste shortcuts", async () => {
302+
const onInput = vi.fn();
303+
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("insert paste");
304+
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
305+
state.lastTerminal!.selection = "insert copy";
306+
307+
const copyEvent = {
308+
key: "Insert",
309+
metaKey: false,
310+
ctrlKey: true,
311+
shiftKey: false,
312+
altKey: false,
313+
preventDefault: vi.fn(),
314+
stopPropagation: vi.fn(),
315+
} as unknown as KeyboardEvent;
316+
expect(state.lastTerminal!.keyHandler!(copyEvent)).toBe(false);
317+
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("insert copy");
318+
319+
const pasteEvent = {
320+
key: "Insert",
321+
metaKey: false,
322+
ctrlKey: false,
323+
shiftKey: true,
324+
altKey: false,
325+
preventDefault: vi.fn(),
326+
stopPropagation: vi.fn(),
327+
} as unknown as KeyboardEvent;
328+
expect(state.lastTerminal!.keyHandler!(pasteEvent)).toBe(false);
329+
await Promise.resolve();
330+
331+
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
332+
expect(onInput).toHaveBeenCalledWith("insert paste", "paste");
333+
});
334+
335+
it.each([
336+
["Option/Alt+Left", { key: "ArrowLeft", altKey: true }, "\x1bb"],
337+
["Option/Alt+Right", { key: "ArrowRight", altKey: true }, "\x1bf"],
338+
["Option/Alt+Backspace", { key: "Backspace", altKey: true }, "\x1b\x7f"],
339+
["Option/Alt+Delete", { key: "Delete", altKey: true }, "\x1bd"],
340+
["Ctrl+Left", { key: "ArrowLeft", ctrlKey: true }, "\x1b[1;5D"],
341+
["Ctrl+Right", { key: "ArrowRight", ctrlKey: true }, "\x1b[1;5C"],
342+
["Ctrl+Backspace", { key: "Backspace", ctrlKey: true }, "\x1b\x7f"],
343+
["Ctrl+Delete", { key: "Delete", ctrlKey: true }, "\x1bd"],
344+
])("normalizes %s into terminal input", (_name, init, expected) => {
345+
const onInput = vi.fn();
346+
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
347+
348+
const event = {
349+
metaKey: false,
350+
ctrlKey: false,
351+
shiftKey: false,
352+
altKey: false,
353+
preventDefault: vi.fn(),
354+
stopPropagation: vi.fn(),
355+
...init,
356+
} as unknown as KeyboardEvent;
357+
const allowed = state.lastTerminal!.keyHandler!(event);
358+
359+
expect(allowed).toBe(false);
360+
expect(event.preventDefault).toHaveBeenCalled();
361+
expect(event.stopPropagation).toHaveBeenCalled();
362+
expect(onInput).toHaveBeenCalledWith(expected, "terminal");
363+
});
364+
197365
it("forwards generated xterm input data such as wheel scroll reports", () => {
198366
const onInput = vi.fn();
199367
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);

frontend/src/renderer/components/XtermTerminal.tsx

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,67 @@ function bracketPastedText(text: string, bracketedPasteMode: boolean): string {
8585
}
8686

8787
function isTerminalCopyShortcut(event: KeyboardEvent): boolean {
88+
if (event.key === "Insert") return event.ctrlKey && !event.altKey && !event.metaKey;
8889
if (event.key.toLowerCase() !== "c") return false;
8990
if (event.metaKey) return true;
91+
if (event.ctrlKey && event.shiftKey && !event.altKey) return true;
92+
return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
93+
}
94+
95+
function isWindowsPlatform(): boolean {
96+
const platform =
97+
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform;
98+
return platform.toLowerCase().startsWith("win");
99+
}
100+
101+
function isTerminalPasteShortcut(event: KeyboardEvent): boolean {
102+
if (event.key === "Insert") return event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey;
103+
if (event.key.toLowerCase() !== "v") return false;
104+
if (event.metaKey) return true;
90105
return event.ctrlKey && event.shiftKey;
91106
}
92107

108+
function consumeTerminalShortcut(event: KeyboardEvent): void {
109+
event.preventDefault();
110+
event.stopPropagation();
111+
}
112+
113+
function normalizedTerminalShortcut(event: KeyboardEvent): string | null {
114+
if (event.metaKey || event.shiftKey) return null;
115+
116+
if (event.altKey && !event.ctrlKey) {
117+
switch (event.key) {
118+
case "ArrowLeft":
119+
return "\x1bb";
120+
case "ArrowRight":
121+
return "\x1bf";
122+
case "Backspace":
123+
return "\x1b\x7f";
124+
case "Delete":
125+
return "\x1bd";
126+
default:
127+
return null;
128+
}
129+
}
130+
131+
if (event.ctrlKey && !event.altKey) {
132+
switch (event.key) {
133+
case "ArrowLeft":
134+
return "\x1b[1;5D";
135+
case "ArrowRight":
136+
return "\x1b[1;5C";
137+
case "Backspace":
138+
return "\x1b\x7f";
139+
case "Delete":
140+
return "\x1bd";
141+
default:
142+
return null;
143+
}
144+
}
145+
146+
return null;
147+
}
148+
93149
function terminalHasFocus(host: HTMLElement): boolean {
94150
const activeElement = document.activeElement;
95151
return !!activeElement && host.contains(activeElement);
@@ -232,9 +288,45 @@ export function XtermTerminal(props: XtermTerminalProps) {
232288
const clearCopiedSelection = () => {
233289
lastCopiedSelection = "";
234290
};
291+
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
292+
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
293+
if (data.length === 0) return;
294+
userInputListeners.forEach((listener) => listener(data, source));
295+
};
296+
const pasteText = (text: string) => {
297+
const prepared = preparePastedText(text);
298+
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
299+
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
300+
};
301+
const pasteFromClipboard = () => {
302+
void aoBridge.clipboard
303+
.readText()
304+
.then(pasteText)
305+
.catch((error) => {
306+
console.warn("Unable to paste terminal clipboard text", error);
307+
});
308+
};
235309
term.attachCustomKeyEventHandler((event) => {
236-
if (!isTerminalCopyShortcut(event) || !copySelection()) return true;
237-
event.preventDefault();
310+
if (isTerminalCopyShortcut(event)) {
311+
if (copySelection()) {
312+
consumeTerminalShortcut(event);
313+
return false;
314+
}
315+
if ((event.ctrlKey && event.shiftKey) || (event.key === "Insert" && event.ctrlKey)) {
316+
consumeTerminalShortcut(event);
317+
return false;
318+
}
319+
return true;
320+
}
321+
if (isTerminalPasteShortcut(event)) {
322+
consumeTerminalShortcut(event);
323+
pasteFromClipboard();
324+
return false;
325+
}
326+
const normalized = normalizedTerminalShortcut(event);
327+
if (!normalized) return true;
328+
consumeTerminalShortcut(event);
329+
emitUserInput(normalized, "terminal");
238330
return false;
239331
});
240332
const copyInput = (event: ClipboardEvent) => {
@@ -338,11 +430,6 @@ export function XtermTerminal(props: XtermTerminalProps) {
338430
// misses them. Listen on window directly as a session-long recovery path.
339431
window.addEventListener("resize", fitTerminal);
340432

341-
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
342-
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
343-
if (data.length === 0) return;
344-
userInputListeners.forEach((listener) => listener(data, source));
345-
};
346433
const terminalInput = term.onData((data) => emitUserInput(data, "terminal"));
347434

348435
// Translate wheel motion into SGR wheel reports for zellij (see
@@ -377,9 +464,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
377464
event.preventDefault();
378465
event.stopPropagation();
379466
const text = event.clipboardData?.getData("text/plain") ?? "";
380-
const prepared = preparePastedText(text);
381-
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
382-
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
467+
pasteText(text);
383468
};
384469
const compositionInput = (event: CompositionEvent) => {
385470
emitUserInput(event.data, "composition");

0 commit comments

Comments
 (0)