mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c2295b1a | ||
|
|
8d60972fdd | ||
|
|
b9f9dbea88 | ||
|
|
a3abb8f979 | ||
|
|
4291adbdfc | ||
|
|
f8c64c879f | ||
|
|
1c1fcf3995 | ||
|
|
15f209d24d | ||
|
|
61a2d50e9f | ||
|
|
c0ed0c3e39 | ||
|
|
671ce69519 | ||
|
|
789a5ef2ec | ||
|
|
a0ef88c085 |
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\);/,
|
||||
);
|
||||
});
|
||||
@@ -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);});
|
||||
}
|
||||
Reference in new issue
Block a user