fix: let Ctrl+C send SIGINT when no text is selected in terminal (#1461)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled

* fix: let Ctrl+C send SIGINT when no text is selected in terminal

When the copy shortcut is customized to Ctrl+C (default Ctrl+Shift+C),
the terminal always consumed the event for copy even without a text
selection, preventing the standard Ctrl+C → SIGINT interrupt signal.

Added a guard in attachCustomKeyEventHandler: if the matched action is
'copy' and term.hasSelection() is false, return true to pass the event
through to xterm.js, which encodes it as \x03 (ETX) for normal SIGINT
delivery. When text IS selected, copy proceeds as before.

This change is platform-agnostic (renderer-side only) and does not
affect any other terminal actions or default key bindings.

* fix: gate copy passthrough on Ctrl+C/⌃C interrupt chord specifically

The previous fix passed ALL no-selection copy bindings through to xterm,
which would send unintended escape/control sequences to the remote
process if copy were bound to keys like F5 or Ctrl+L.

Now gate the passthrough on the exact interrupt chord: Ctrl+C (PC) or
⌃C (Mac) — key 'c' with only Ctrl pressed, no Shift/Alt/Meta. Any other
copy binding with no selection is consumed as a safe no-op.

* fix: preserve Ctrl-C passthrough for copy shortcut

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
This commit is contained in:
yabirthday
2026-06-14 01:49:21 +08:00
committed by GitHub
parent f5c3302329
commit 88142d2a92
3 changed files with 82 additions and 0 deletions

View File

@@ -61,6 +61,7 @@ import {
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
@@ -704,6 +705,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
return true;
}
// When copy is bound specifically to Ctrl+C and there is no text
// selected, pass the event through so xterm can send SIGINT.
if (shouldPassThroughCopyShortcut(action, term.hasSelection(), e)) {
return true;
}
e.preventDefault();
e.stopPropagation();
switch (action) {

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isPlainCtrlCInterruptChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
const keyboardEvent = (
key: string,
code: string,
modifiers: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key,
code,
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
...modifiers,
}) as KeyboardEvent;
test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});

View File

@@ -0,0 +1,20 @@
type CopyShortcutKeyEvent = Pick<
KeyboardEvent,
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
}
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
}