fix: avoid duplicate terminal paste shortcuts (#400)

* fix: avoid duplicate terminal paste shortcuts

* chore: format with prettier [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Laxman 2026-06-23 18:23:00 +05:30 committed by GitHub
parent 7ac1e2caa2
commit f15021c19d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 94 additions and 5 deletions

View File

@ -277,16 +277,54 @@ describe("XtermTerminal", () => {
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
});
it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => {
it.each(["Linux x86_64", "Win32"])(
"pastes once from the Electron clipboard on Ctrl+Shift+V for %s",
async (platform) => {
setNavigatorPlatform(platform);
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
const { container } = 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);
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, "clipboardData", {
value: { getData: vi.fn().mockReturnValue("native paste") },
});
container.firstElementChild!.dispatchEvent(pasteEvent);
await Promise.resolve();
expect(allowed).toBe(false);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(window.ao!.clipboard.readText).toHaveBeenCalledTimes(1);
expect(pasteEvent.defaultPrevented).toBe(true);
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
},
);
it("supports plain Ctrl+V paste on Windows", async () => {
setNavigatorPlatform("Win32");
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("windows paste");
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
const event = {
key: "v",
metaKey: false,
ctrlKey: true,
shiftKey: true,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
@ -298,7 +336,36 @@ describe("XtermTerminal", () => {
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
expect(onInput).toHaveBeenCalledWith("windows paste", "paste");
});
it("suppresses a queued native paste event after a handled paste shortcut", async () => {
const onInput = vi.fn();
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("shortcut paste");
const { container } = 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;
expect(state.lastTerminal!.keyHandler!(event)).toBe(false);
await new Promise((resolve) => window.setTimeout(resolve, 0));
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
Object.defineProperty(pasteEvent, "clipboardData", {
value: { getData: vi.fn().mockReturnValue("native paste") },
});
container.firstElementChild!.dispatchEvent(pasteEvent);
await Promise.resolve();
expect(pasteEvent.defaultPrevented).toBe(true);
expect(onInput).toHaveBeenCalledTimes(1);
expect(onInput).toHaveBeenCalledWith("shortcut paste", "paste");
});
it("supports classic Windows terminal copy and paste shortcuts", async () => {

View File

@ -68,6 +68,7 @@ function loadRenderer(term: Terminal): void {
// xterm palette tracks the app theme (see lib/terminal-themes.ts + --term-* in
// styles.css). The PTY content is still the agent's own ANSI output.
const terminalThemes = buildTerminalThemes();
const SUPPRESS_NATIVE_PASTE_MS = 100;
// Erase scrollback (3J) + display (2J) and home the cursor — yyork's
// terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a
@ -102,7 +103,8 @@ 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;
if (event.ctrlKey && event.shiftKey && !event.altKey) return true;
return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
}
function consumeTerminalShortcut(event: KeyboardEvent): void {
@ -298,6 +300,20 @@ export function XtermTerminal(props: XtermTerminalProps) {
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
};
let suppressNextNativePaste = false;
let suppressPasteTimer: number | null = null;
const clearSuppressNativePaste = () => {
suppressNextNativePaste = false;
if (suppressPasteTimer !== null) {
window.clearTimeout(suppressPasteTimer);
suppressPasteTimer = null;
}
};
const suppressNativePasteOnce = () => {
suppressNextNativePaste = true;
if (suppressPasteTimer !== null) window.clearTimeout(suppressPasteTimer);
suppressPasteTimer = window.setTimeout(clearSuppressNativePaste, SUPPRESS_NATIVE_PASTE_MS);
};
const pasteFromClipboard = () => {
void aoBridge.clipboard
.readText()
@ -320,6 +336,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
}
if (isTerminalPasteShortcut(event)) {
consumeTerminalShortcut(event);
suppressNativePasteOnce();
pasteFromClipboard();
return false;
}
@ -468,6 +485,10 @@ export function XtermTerminal(props: XtermTerminalProps) {
const pasteInput = (event: ClipboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (suppressNextNativePaste) {
clearSuppressNativePaste();
return;
}
const text = event.clipboardData?.getData("text/plain") ?? "";
pasteText(text);
};
@ -510,6 +531,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
selectionChange.dispose();
host.removeEventListener("paste", pasteInput, true);
host.removeEventListener("compositionend", compositionInput, true);
clearSuppressNativePaste();
keyInput.dispose();
userInputListeners.clear();
try {