Compare commits

...
8 changed files with 549 additions and 20 deletions

No files matched your search

@@ -1029,3 +1029,62 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
assert.match(writeSite, /ctx\.onOutputTriggerUserInputRef\?\.current\?\.\(outData\)/);
assert.match(source, /onBroadcastInput\?\.\(broadcastData, ctx\.sessionId\)/);
});
test("Command+Period interrupt press is keyed apart from an outstanding physical KeyC press (#3409)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The normalized Ctrl+C press shares its event identity with a possibly
// outstanding physical KeyC press, so it must be recorded under a dedicated
// map key: upserting under "KeyC" replaced the held key's press and the
// Period keyup deleted that shared entry while C was still down.
assert.match(
source,
/const pressIdentity =\s*macCommandPeriodInterrupt\s*\?\s*kittyNormalizedPressIdentity\(identity\)\s*:\s*identity,?/,
);
assert.match(source, /kittyNormalizedPressAliases\.set\(kittyKeyIdentity\(e\), pressIdentity\)/);
assert.match(
source,
/upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,\s*pressIdentity,/,
);
assert.match(
source,
/upsertKittyKeyboardForwardedPress\(\s*broadcastForwardedKeys,\s*pressIdentity,/,
);
// The aliased Period keyup releases the interrupt press under its dedicated
// key, leaving the physical KeyC press entry intact.
assert.match(source, /\{ \.\.\.aliasedRelease\.event, type: "keyup" \},\s*aliasedRelease\.identity/);
assert.match(source, /releaseForwardedKittyPress\(toKittyKeyboardEvent\(releaseEvent\)\) \|\| releasedInterrupt/);
// The dedicated identity crosses the broadcast boundary: peers key their
// pairing state from it, so the interrupt cannot collapse with an
// outstanding physical KeyC press on legacy or Kitty peers (#3409).
assert.match(
source,
/broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*keyIdentity: pressIdentity,\s*\}\)/,
);
assert.match(
source,
/broadcastKittyInput\(\{\s*kind: "legacy",\s*data: "\\x03",\s*keyIdentity: pressIdentity,/,
);
// The paired release carries the identity the press was recorded under.
assert.match(
source,
/\{ kind: "key", event, keyIdentity: identity \}/,
);
// Native keyups retain the physical event even after releasing an alias.
assert.match(source, /const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);/);
});
test("Command+Period interrupt yields to a user-assigned snippet or shortcut chord (#3409)", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The snippet/app-shortcut editors accept Command+Period (their conflict checks only
// cover configured bindings), so the hard-coded interrupt must give a
// configured chord precedence instead of silently swallowing it (#3409).
assert.match(
source,
/const macCommandPeriodInterrupt =\s*isMacPlatform\(\)\s*&& isMacCommandPeriodInterruptChord\(e\)\s*&& !\(ctx\.snippetsRef\?\.current \?\? \[\]\)\.some\(\(snippet\) => \(\s*snippet\.shortkey && matchesKeyBinding\(e, snippet\.shortkey, isMac\)\s*\)\)\s*&& !\(currentScheme !== "disabled"\s*&& checkAppShortcut\(e, ctx\.keyBindingsRef\.current, isMac\) !== null\);/,
);
});
+144 -17
View File
@@ -161,7 +161,10 @@ import {
type TerminalOutputHistoryPreview,
} from "./terminalOutputHistory";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
import {
isMacCommandPeriodInterruptChord,
shouldUseUrgentTerminalInterrupt,
} from "./terminalInterruptShortcut";
import {
createTerminalInterruptTrace,
logTerminalInterruptTrace,
@@ -1486,6 +1489,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
// Command+Period interrupt presses are recorded under their normalized Ctrl+C identity
// (KeyC) so broadcast legacy pairing stays matched (#3408), but under a
// dedicated map key so they cannot clobber an outstanding physical KeyC
// press; map the physical chord identity (Period) to it so the later keyup
// can pair the release.
const kittyNormalizedPressAliases = new Map<string, string>();
const kittyNormalizedPressIdentity = (identity: string): string =>
`${identity}\u0000mac-period-interrupt`;
const broadcastEncodedKeys = new Set<string>();
const broadcastLegacySuppressedKeys = new Set<string>();
const kittyKeyIdentity = (event: KeyboardEvent): string => event.code || event.key;
@@ -1671,13 +1682,20 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
*/
const releaseForwardedKittyPress = (
event: Pick<KittyKeyboardEvent, "code" | "key"> & KittyKeyboardEvent,
identityOverride?: string,
): boolean => {
const identity = event.code || event.key;
// The Command+Period interrupt press is keyed independently from its normalized
// Ctrl+C event identity, so its release must delete the entry it was
// stored under rather than the physical key's (#3408).
const identity = identityOverride ?? (event.code || event.key);
const forwardedPress = broadcastForwardedKeys.get(identity);
if (forwardedPress) {
broadcastForwardedKeys.delete(identity);
broadcastKittyInput(
{ kind: "key", event },
// Carry the identity the press was recorded under so peers pair this
// release with that press instead of the event's physical code - the
// Command+Period interrupt press lives under a dedicated normalized key (#3409).
{ kind: "key", event, keyIdentity: identity },
true,
forwardedPress.targetSessionIds,
);
@@ -1693,6 +1711,30 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
return false;
};
/**
* Resolve a forwarded press whose recorded identity is not the physical
* key identity: the Command+Period interrupt press was recorded as the normalized
* Ctrl+C event (KeyC), but the browser delivers the physical release as
* Period. Pair that release from the stored event so Kitty consumers do
* not see Ctrl+C held until focus loss (#3408).
*/
const resolveKittyNormalizedPressRelease = (
physicalEvent: KeyboardEvent,
): { event: KittyKeyboardEvent; identity: string } | null => {
const physicalIdentity = kittyKeyIdentity(physicalEvent);
const normalizedIdentity = kittyNormalizedPressAliases.get(physicalIdentity);
if (!normalizedIdentity) return null;
const forwardedPress =
broadcastForwardedKeys.get(normalizedIdentity)
?? kittyForwardedKeys.get(normalizedIdentity);
// Consume the alias on every path once it has been paired (or
// invalidated): a later keyup for the same physical key while another
// KeyC press is outstanding must not be rewritten as the normalized
// release (#3408).
kittyNormalizedPressAliases.delete(physicalIdentity);
if (!forwardedPress) return null;
return { event: forwardedPress.event, identity: normalizedIdentity };
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
@@ -1770,6 +1812,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" });
}
}
// Release the normalized interrupt separately: the same physical key
// may already have a forwarded press from before Command was held.
// Keep the saved layout-independent event rather than translating KeyC
// through the current keyboard layout again.
const aliasedRelease = resolveKittyNormalizedPressRelease(e);
const releasedInterrupt = aliasedRelease !== null && releaseForwardedKittyPress(
{ ...aliasedRelease.event, type: "keyup" },
aliasedRelease.identity,
);
const identity = kittyKeyIdentity(releaseEvent);
const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(identity);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
@@ -1792,7 +1843,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
return true;
}
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) {
if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent)) || releasedInterrupt) {
e.preventDefault();
e.stopPropagation();
return false;
@@ -1981,10 +2032,27 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
kittyKeyboardProtocolEnabled
? encodeKittyKeyEvent(kittyKeyboardMode, toKittyKeyboardEvent(e))
: null;
if (
const urgentInterrupt =
(!kittySequenceForKeyDown || kittySequenceForKeyDown === "\x03") &&
shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection })
) {
shouldUseUrgentTerminalInterrupt(e, { hasSelection: hasCopyableSelection });
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
// macOS Terminal convention: Command+Period interrupts the running command like
// Ctrl+C (#3408), including while text is selected. A
// user-assigned snippet or configured shortcut on this chord keeps
// precedence: the editors accept Command+Period (their conflict check only covers
// configured bindings), so the hard-coded interrupt must not silently
// swallow a chord the user actually assigned (#3409).
const macCommandPeriodInterrupt =
isMacPlatform()
&& isMacCommandPeriodInterruptChord(e)
&& !(ctx.snippetsRef?.current ?? []).some((snippet) => (
snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)
))
&& !(currentScheme !== "disabled"
&& checkAppShortcut(e, ctx.keyBindingsRef.current, isMac) !== null);
if (urgentInterrupt || macCommandPeriodInterrupt) {
const id = ctx.sessionRef.current;
if (id && ctx.statusRef.current === "connected") {
const rendererKeyAt = Date.now();
@@ -2035,8 +2103,53 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
} else {
ctx.terminalBackend.writeToSession(id, "\x03");
}
const kittyEvent = toKittyKeyboardEvent(e);
const identity = kittyKeyIdentity(e);
// Report the interrupt to Kitty as Ctrl+C even when it came from the
// Command+Period chord: the broadcast legacy \x03 is keyed by this identity, so
// forwarding Super+Period would leave peers with an unmatched
// Super+Period press and suppress the interrupt instead (#3408).
const interruptEventForKitty: KeyboardEvent = macCommandPeriodInterrupt
? {
type: e.type,
key: "c",
code: "KeyC",
location: e.location,
repeat: e.repeat,
isComposing: e.isComposing,
keyCode: 67,
shiftKey: false,
altKey: false,
ctrlKey: true,
metaKey: false,
getModifierState: (key: string) => key === "Control" && e.getModifierState("Control"),
} as unknown as KeyboardEvent
: e;
const kittyEvent = toKittyKeyboardEvent(interruptEventForKitty);
if (macCommandPeriodInterrupt) {
// The synthesized event's code ("KeyC") is the physical QWERTY
// position of the chord key, so toKittyKeyboardEvent()'s layout
// lookup returns that position's character on non-QWERTY layouts
// (e.g. "n" under Dvorak) and getUnicodeKeyCode() prioritizes it,
// encoding the interrupt as the wrong key instead of Ctrl+C's 99
// - a broadcast peer would then suppress the legacy \x03 fallback
// and never be interrupted. Force the layout-independent identity.
kittyEvent.unshiftedKey = "c";
}
const identity = kittyKeyIdentity(interruptEventForKitty);
// The normalized press shares the Ctrl+C event identity (KeyC) with a
// possibly outstanding physical KeyC press; record it under a dedicated
// map key even when the layout maps period to physical KeyC. Its keyup
// releases (and deletes) only the
// interrupt press instead of the held physical key's entry (#3409).
const pressIdentity =
macCommandPeriodInterrupt
? kittyNormalizedPressIdentity(identity)
: identity;
if (pressIdentity !== identity) {
// The physical release arrives under its original layout key while
// the press was recorded as the normalized Ctrl+C event; pair them
// at keyup so the interrupt release is not lost (#3408).
kittyNormalizedPressAliases.set(kittyKeyIdentity(e), pressIdentity);
}
if (
!term.modes.win32InputMode &&
kittyKeyboardProtocolEnabled &&
@@ -2044,23 +2157,31 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressIdentity,
kittyEvent,
[],
);
}
const forwarded = broadcastKittyInput({ kind: "key", event: kittyEvent });
// The dedicated press identity must cross the broadcast boundary:
// peers key their pairing state from it, so broadcasting only the
// normalized event would collapse the interrupt with an outstanding
// physical KeyC press on every peer (#3409).
const forwarded = broadcastKittyInput({
kind: "key",
event: kittyEvent,
keyIdentity: pressIdentity,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressIdentity,
kittyEvent,
forwarded.targetSessionIds,
);
broadcastKittyInput({
kind: "legacy",
data: "\x03",
keyIdentity: identity,
keyIdentity: pressIdentity,
urgentInterrupt: true,
});
}
@@ -2069,10 +2190,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
const currentScheme = ctx.hotkeySchemeRef.current;
// Use shared utility for platform detection when hotkey scheme is disabled
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
// Check snippet shortcuts first (even if hotkeys are disabled)
const snippets = ctx.snippetsRef?.current;
if (snippets && snippets.length > 0) {
@@ -2479,6 +2596,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
win32InputModePendingEvent = null;
win32InputModeForwardedKeys.clear();
kittyForwardedKeys.clear();
// broadcastForwardedKeys is retained so pending peer releases still pair
// after a reconnect; keep the aliases whose normalized press is still
// owed a broadcast release, otherwise the physical keyup can no longer
// find the dedicated identity and peers keep the key logically pressed
// until blur (#3409).
for (const [physicalIdentity, normalizedIdentity] of kittyNormalizedPressAliases) {
if (!broadcastForwardedKeys.has(normalizedIdentity)) {
kittyNormalizedPressAliases.delete(physicalIdentity);
}
}
clearKittyKeyboardBroadcastPairingState(
broadcastEncodedKeys,
broadcastLegacySuppressedKeys,
@@ -1021,3 +1021,80 @@ test("a key pressed while disconnected cannot produce an orphan release after re
});
assert.deepEqual(writes, []);
});
test("a dedicated keyIdentity pairs the interrupt press with its release (#3409)", () => {
const options = () => {
// Disambiguate + event-type flags so Ctrl+C press/release encode as CSI-u.
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, 1 | 2);
return {
kittyProtocolEnabled: true,
kittyMode: mode,
applicationCursorMode: false,
encodedKeys: new Set<string>(),
legacySuppressedKeys: new Set<string>(),
};
};
// A Command+Period interrupt is normalized to a Ctrl+C event but paired under a
// dedicated identity, so it must not collapse with an outstanding physical
// KeyC press on the peer: the press, the legacy suppression and the release
// all use the propagated identity, while the physical KeyC state survives.
const pressOptions = options();
const interruptPress = resolveKittyKeyboardBroadcastInput({
kind: "key",
event: { type: "keydown", key: "c", code: "KeyC", ctrlKey: true },
keyIdentity: "KeyC mac-period-interrupt",
urgentInterrupt: true,
}, pressOptions);
assert.equal(interruptPress?.data, "\x1b[99;5u");
assert.ok(pressOptions.encodedKeys.has("KeyC mac-period-interrupt"));
assert.ok(pressOptions.legacySuppressedKeys.has("KeyC mac-period-interrupt"));
// The physical KeyC press keeps its own entry.
assert.equal(pressOptions.encodedKeys.has("KeyC"), false);
// The legacy \x03 fan-out under the dedicated identity is suppressed on a
// Kitty peer (already encoded) but delivered on a legacy peer.
assert.equal(resolveKittyKeyboardBroadcastInput({
kind: "legacy",
data: "\x03",
keyIdentity: "KeyC mac-period-interrupt",
urgentInterrupt: true,
}, pressOptions), null);
const legacyPeerOptions = options();
const legacyInterrupt = resolveKittyKeyboardBroadcastInput({
kind: "legacy",
data: "\x03",
keyIdentity: "KeyC mac-period-interrupt",
urgentInterrupt: true,
}, legacyPeerOptions);
assert.equal(legacyInterrupt?.data, "\x03");
assert.equal(legacyInterrupt?.urgentInterrupt, true);
// The release pairs under the dedicated identity, leaving the physical
// KeyC press entry intact for its own keyup.
const releaseOptions = options();
releaseOptions.encodedKeys.add("KeyC mac-period-interrupt");
releaseOptions.encodedKeys.add("KeyC");
releaseOptions.legacySuppressedKeys.add("KeyC");
const interruptRelease = resolveKittyKeyboardBroadcastInput({
kind: "key",
event: { type: "keyup", key: "c", code: "KeyC", ctrlKey: true },
keyIdentity: "KeyC mac-period-interrupt",
}, releaseOptions);
assert.equal(interruptRelease?.data, "\x1b[99;5:3u");
assert.ok(releaseOptions.encodedKeys.has("KeyC"));
assert.equal(releaseOptions.encodedKeys.has("KeyC mac-period-interrupt"), false);
// Without an explicit identity, pairing still keys from the event code.
const defaultOptions = options();
defaultOptions.encodedKeys.add("KeyA");
const defaultRelease = resolveKittyKeyboardBroadcastInput({
kind: "key",
event: { type: "keyup", key: "a", code: "KeyA" },
}, defaultOptions);
assert.equal(defaultRelease?.data, "\x1b[97;1:3u");
assert.equal(defaultOptions.encodedKeys.has("KeyA"), false);
});
@@ -19,6 +19,12 @@ export type KittyKeyboardBroadcastInput =
event: KittyKeyboardEvent;
fallbackToLegacy?: boolean;
urgentInterrupt?: boolean;
/**
* Pairing identity for peers whose state is keyed by correlation id
* rather than the event's physical code. Defaults to the event's code
* so ordinary key broadcasts are unchanged.
*/
keyIdentity?: string;
}
| {
kind: "win32";
@@ -196,6 +202,10 @@ export const flushKittyKeyboardBroadcastReleases = (
pending.delete(identity);
forward({
kind: "key",
// Keep the identity the press was recorded under so a peer whose
// pairing diverges from the event's physical code (e.g. a Command+Period interrupt
// normalized to Ctrl+C) still pairs this synthetic release (#3409).
keyIdentity: identity,
event: createKittyKeyboardSyntheticRelease(
forwardedPress.event,
Array.from(pending.values(), (pendingPress) => pendingPress.event),
@@ -309,7 +319,7 @@ export const resolveKittyKeyboardBroadcastInput = (
}, options);
}
const identity = input.event.code || input.event.key;
const identity = input.keyIdentity ?? (input.event.code || input.event.key);
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
const hasPairedKeyDown = input.event.type === "keyup"
? options.encodedKeys.delete(identity)
@@ -472,7 +472,7 @@ test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", ()
// Any real key release ends the deferral, not just an exact key match.
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
assert.ok(keyupIdx >= 0);
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2800);
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 3600);
assert.match(
keyupSlice,
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
import {
isMacCommandPeriodInterruptChord,
shouldUseUrgentTerminalInterrupt,
} from "./terminalInterruptShortcut";
function key(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
@@ -40,3 +43,50 @@ test("urgent interrupt leaves copy shortcuts and modified chords alone", () => {
assert.equal(shouldUseUrgentTerminalInterrupt(key({ metaKey: true }), { hasSelection: false }), false);
assert.equal(shouldUseUrgentTerminalInterrupt(key({ altKey: true }), { hasSelection: false }), false);
});
test("mac Command+Period chord is recognized as an interrupt", () => {
assert.equal(
isMacCommandPeriodInterruptChord(key({ key: ".", code: "Period", ctrlKey: false, metaKey: true })),
true,
);
// Non-Latin layouts may report the physical Period code with a mapped key.
assert.equal(
isMacCommandPeriodInterruptChord(key({ key: "\u3002", code: "Period", ctrlKey: false, metaKey: true })),
true,
);
});
test("mac Command+Period chord requires Meta and rejects Ctrl or Alt", () => {
const chord = { key: ".", code: "Period", ctrlKey: false, metaKey: true };
assert.equal(isMacCommandPeriodInterruptChord(key({ ...chord, shiftKey: true })), true);
assert.equal(isMacCommandPeriodInterruptChord(key({ ...chord, altKey: true })), false);
assert.equal(isMacCommandPeriodInterruptChord(key({ ...chord, ctrlKey: true })), false);
assert.equal(isMacCommandPeriodInterruptChord(key({ key: ".", code: "Period", metaKey: false })), false);
});
test("mac Command+Period chord prefers the layout character over the physical key", () => {
assert.equal(
isMacCommandPeriodInterruptChord(key({ key: ",", code: "Period", ctrlKey: false, metaKey: true })),
false,
);
assert.equal(
isMacCommandPeriodInterruptChord(key({ key: ".", code: "KeyJ", ctrlKey: false, metaKey: true })),
true,
);
assert.equal(
isMacCommandPeriodInterruptChord(key({ key: "\u044e", code: "Period", ctrlKey: false, metaKey: true })),
true,
);
});
test("mac Command+Period accepts Shift when it produces the logical period", () => {
assert.equal(isMacCommandPeriodInterruptChord(key({
key: ".", code: "Semicolon", ctrlKey: false, metaKey: true, shiftKey: true,
})), true);
assert.equal(isMacCommandPeriodInterruptChord(key({
key: ">", code: "Period", ctrlKey: false, metaKey: true, shiftKey: true,
})), false);
assert.equal(isMacCommandPeriodInterruptChord(key({
key: "\u3002", code: "Period", ctrlKey: false, metaKey: true, shiftKey: true,
})), false);
});
@@ -1,5 +1,20 @@
type InterruptShortcutEvent = Pick<KeyboardEvent, "altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey">;
/**
* macOS Terminal convention: Command-Period interrupts the running
* command, equivalent to Ctrl+C. Netcatty does not bind Command-Period elsewhere, so on
* macOS it is forwarded as a SIGINT interrupt (#3408).
*/
export function isMacCommandPeriodInterruptChord(
event: InterruptShortcutEvent,
): boolean {
if (!event.metaKey || event.ctrlKey || event.altKey) return false;
// Shift can produce the logical period (for example on AZERTY).
// Only the physical-key fallback requires an unshifted event.
if (/^[\x20-\x7e]$/.test(event.key)) return event.key === ".";
return !event.shiftKey && event.code === "Period";
}
export function shouldUseUrgentTerminalInterrupt(
event: InterruptShortcutEvent,
options: { hasSelection: boolean },
@@ -0,0 +1,191 @@
"use strict";
/* global process, __dirname, console */
if (!process.versions.electron || process.platform !== "darwin") {
require("node:test")("macOS Command-period interrupts without losing held keys", {
skip: "run with Electron on macOS for real xterm keyboard coverage",
}, () => {});
} else {
const { app, BrowserWindow, ipcMain } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("command-period-test")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const finish = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
const bundle = require("esbuild").buildSync({
stdin: {
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchKittyKeyboardBroadcastInput} from "./components/terminal/runtime/kittyKeyboardBroadcast";',
loader: "ts", resolveDir: path.resolve(__dirname, ".."),
},
bundle: true, format: "cjs", platform: "browser", write: false,
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
void app.whenReady().then(async () => {
win = new BrowserWindow({
show: true, width: 1000, height: 600,
webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false },
});
const rendererErrors = [];
win.webContents.on("console-message", details => {
if (details.level === "error") rendererErrors.push(details.message);
});
await win.loadURL("data:text/html,<body style='background:%23111'></body>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
ipcMain.once("command-period-ready", () => {
win.webContents.sendInputEvent({ type: "keyDown", keyCode: ".", modifiers: ["meta"] });
win.webContents.sendInputEvent({ type: "keyUp", keyCode: ".", modifiers: ["meta"] });
});
const result = await win.webContents.executeJavaScript(`(async () => {
const assert = require('node:assert/strict');
const loaded = {exports:{}};
((module,exports)=>{${bundle}})(loaded,loaded.exports);
const {createXTermRuntime,DEFAULT_TERMINAL_SETTINGS,dispatchKittyKeyboardBroadcastInput} = loaded.exports;
// Force a non-QWERTY layout: physical KeyC maps to j.
const layoutMap=new Map([['KeyC','j'],['Period','.']]);
Object.defineProperty(navigator,'keyboard',{configurable:true,value:{
getLayoutMap:async()=>layoutMap,
}});
const ref = current => ({current});
const panes = [];
const make = (id, broadcast) => {
const el=document.createElement('div');
el.style.cssText='width:450px;height:240px;display:inline-block';document.body.appendChild(el);
const writes=[];let sink=()=>{};
const send=data=>{writes.push(data);sink(data);};
const r=createXTermRuntime({
container:el,host:{id,label:id,hostname:'localhost',protocol:'local'},
fontFamilyId:'jetbrains-mono',resolvedFontFamily:'monospace',fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,cursorBlink:false}),
terminalBackend:{writeToSession:(_,data)=>send(data),interruptSession:()=>send('\\x03'),resizeSession:()=>{},openExternalAvailable:false},
sessionRef:ref(id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(Boolean(broadcast)),
onBroadcastInputRef:ref(broadcast),sessionId:id,statusRef:ref('connected'),
commandBufferRef:ref(''),requestSearchFocus:()=>{},kittyKeyboardProtocolEnabled:true,
});
r.fitAddon.fit(); const pane={r,el,writes,setSink:next=>{sink=next;}};panes.push(pane);return pane;
};
const write=(p,data)=>new Promise(resolve=>p.r.term.write(data,resolve));
const key=(p,type,key,code,mods={})=>p.r.term.textarea.dispatchEvent(new KeyboardEvent(type,{
bubbles:true,cancelable:true,key,code,keyCode:code==='Period'?190:67,...mods,
}));
const peer=make('peer');
const source=make('source',(data,__,options)=>{
if(options?.kittyKeyboardInput)dispatchKittyKeyboardBroadcastInput('peer',options.kittyKeyboardInput);
else peer.writes.push(data);
return ['peer'];
});
await write(peer,'\\x1b[>11u');
source.r.term.options.vtExtensions={...source.r.term.options.vtExtensions,win32InputMode:true};
const summary=[];
for(const mode of ['kitty','win32','legacy']) {
await write(source,'\\x1b[?9001l\\x1b[<u');
if(mode==='kitty')await write(source,'\\x1b[>11u');
if(mode==='win32')await write(source,'\\x1b[?9001h');
assert.equal(source.r.term.modes.win32InputMode,mode==='win32');
source.r.term.focus();source.writes.length=0;peer.writes.length=0;
key(source,'keydown','.','Period',mode==='win32'?{ctrlKey:true}:{});
if(mode!=='win32')source.r.term.textarea.dispatchEvent(new InputEvent('input',{
bubbles:true,inputType:'insertText',data:'.',
}));
key(source,'keydown','.','Period',{metaKey:true,repeat:true});
key(source,'keyup','.','Period',{metaKey:true});
assert.ok(source.writes.includes('\\x03'),mode+' interrupts the source');
assert.ok(peer.writes.some(s=>s.includes('[99;5:3u')),mode+' peer releases C on Dvorak');
assert.ok(peer.writes.some(s=>/\\[46;\\d+:3u/.test(s)),mode+' peer releases held Period '+JSON.stringify({source:source.writes,peer:peer.writes}));
if(mode==='kitty') {
assert.ok(source.writes.some(s=>s.includes('[99;5:3u')),'source releases C on Dvorak');
assert.ok(source.writes.some(s=>/\\[46;\\d+:3u/.test(s)),'source releases held Period');
}
if(mode==='win32') {
assert.ok(source.writes.some(s=>/^\\x1b\\[190;\\d+;\\d+;0;/.test(s)),
'native source releases physical Period: '+JSON.stringify(source.writes));
assert.ok(!source.writes.some(s=>/^\\x1b\\[67;\\d+;\\d+;0;/.test(s)),
'native source never releases an unpressed physical C');
}
summary.push({mode,source:[...source.writes],peer:[...peer.writes]});
// The interrupt release must not consume a separately held C.
source.writes.length=0;peer.writes.length=0;
key(source,'keydown','c','KeyC');
key(source,'keydown','.','Period',{metaKey:true});
key(source,'keyup','.','Period',{metaKey:true});
const before=peer.writes.length;
key(source,'keyup','c','KeyC');
assert.ok(peer.writes.slice(before).some(s=>/\\[106;\\d+:3u/.test(s)),
mode+' physical C (layout j) remains paired independently');
}
await write(source,'\\x1b[>11u');
layoutMap.set('KeyC','.');
source.writes.length=0;peer.writes.length=0;
key(source,'keydown','.','KeyC');
source.r.term.textarea.dispatchEvent(new InputEvent('input',{
bubbles:true,inputType:'insertText',data:'.',
}));
key(source,'keydown','.','KeyC',{metaKey:true,repeat:true});
key(source,'keyup','.','KeyC',{metaKey:true});
for(const pane of [source,peer]) {
assert.ok(pane.writes.some(s=>s.includes('[99;5:3u')),
'logical period on physical C releases normalized C');
assert.ok(pane.writes.some(s=>/\\[46;\\d+:3u/.test(s)),
'logical period on physical C also releases the original period');
}
layoutMap.set('KeyC','j');
layoutMap.set('Semicolon',';');
source.writes.length=0;peer.writes.length=0;
key(source,'keydown','.','Semicolon',{metaKey:true,shiftKey:true});
key(source,'keyup','.','Semicolon',{metaKey:true,shiftKey:true});
assert.ok(source.writes.includes('\\x03'),'Shift-produced period interrupts');
assert.ok(peer.writes.some(s=>s.includes('[99;5u')),'Shift-produced period broadcasts Ctrl+C');
assert.ok(peer.writes.some(s=>s.includes('[99;5:3u')),'Shift-produced period releases Ctrl+C');
summary.push({periodOnPhysicalC:true,shiftProducedPeriod:true});
await write(source,'\\x1b[<u');
await write(source,'selected output\\r\\n');
source.r.term.selectAll();
assert.ok(source.r.term.hasSelection(),'selection scenario is active');
source.writes.length=0;
key(source,'keydown','.','Period',{metaKey:true});
key(source,'keyup','.','Period',{metaKey:true});
assert.ok(source.writes.includes('\\x03'),'Command-period interrupts with selected text');
summary.push({selectionInterrupt:true});
// Native Electron input must interrupt an actual foreground command.
const smoke=make('shell');smoke.r.term.focus();
const pty=require(${JSON.stringify(require.resolve("node-pty"))});
const shell=pty.spawn('/bin/sh',['-i'],{
name:'xterm-256color',cols:80,rows:24,env:{...process.env,PS1:'PROMPT> '},
});
let output='';
shell.onData(data=>{output+=data;smoke.r.term.write(data);});
smoke.setSink(data=>shell.write(data));
const waitFor=async predicate=>{
const start=Date.now();
while(!predicate()) {
if(Date.now()-start>8000)throw new Error('shell smoke timeout: '+output);
await new Promise(resolve=>setTimeout(resolve,30));
}
};
try {
await waitFor(()=>output.includes('PROMPT> '));
output='';
shell.write("echo __TAIL_''READY__; tail -f /dev/null\\r");
await waitFor(()=>output.includes('__TAIL_READY__\\r\\n'));
await new Promise(resolve=>setTimeout(resolve,150));
output='';
require('electron').ipcRenderer.send('command-period-ready');
await waitFor(()=>output.includes('PROMPT> '));
shell.write("echo __AFTER_''INTERRUPT__\\r");
await waitFor(()=>output.includes('__AFTER_INTERRUPT__\\r\\n'));
summary.push({nativeCommandPeriod:true,tailInterrupted:true,shellUsable:true});
} finally {shell.kill();}
panes.forEach(p=>p.r.dispose());return summary;
})()`);
require("node:assert/strict").deepEqual(rendererErrors, [], "no renderer errors");
console.log("COMMAND_PERIOD_OK", JSON.stringify(result));finish(0);
}).catch(error=>{console.error(error);finish(1);});
}