-
Notifications
You must be signed in to change notification settings - Fork 529
fix(terminal): map macOS Command+. to interrupt running command like Ctrl+C #3409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a0ef88c
789a5ef
671ce69
c0ed0c3
61a2d50
15f209d
1c1fcf3
f8c64c8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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>(); | ||
| // ⌘. 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 ⌘. 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 | ||
| // ⌘. 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 ⌘. 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 | ||
|
|
@@ -1744,6 +1786,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime | |
| // key they consumed (or drop the keyup), and an exact key match left the | ||
| // deferral armed so every later keypress was swallowed (#3103). | ||
| let releaseEvent: KeyboardEvent = e; | ||
| let aliasedReleaseIdentity: string | undefined; | ||
| if ( | ||
| imeTextInputDeferredKey !== null && | ||
| shouldFlushDeferredImeTextInputOnKeyUp(imeTextInputDeferredKey, e) | ||
|
|
@@ -1770,14 +1813,36 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime | |
| releaseForwardedKittyPress({ ...deferredKittyEvent, type: "keyup" }); | ||
| } | ||
| } | ||
| // A ⌘. interrupt press was recorded under its normalized Ctrl+C | ||
| // identity; pair the physical Period release from the stored event | ||
| // instead of leaving the press unmatched (#3408). The release must | ||
| // delete the press under its dedicated key so an outstanding physical | ||
| // KeyC press is left intact (#3409). | ||
| const aliasedRelease = resolveKittyNormalizedPressRelease(e); | ||
| if (aliasedRelease) { | ||
| releaseEvent = { | ||
| ...aliasedRelease.event, | ||
| type: "keyup", | ||
| } as unknown as KeyboardEvent; | ||
| aliasedReleaseIdentity = aliasedRelease.identity; | ||
|
Comment on lines
+1821
to
+1827
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Period is already held and auto-repeating when Command is pressed, its original keydown has been forwarded under Useful? React with 👍 / 👎. |
||
| } | ||
| // The aliased ⌘. release must be looked up under the physical key's | ||
| // identity: the interrupt keydown was consumed by the shortcut and | ||
| // never handed to ConPTY, so the rewritten KeyC identity must not | ||
| // consume an outstanding physical KeyC press's Win32 entry (which | ||
| // would release C natively and drop the real C keyup later), and the | ||
| // aliased interrupt release must stay orphaned (#3409). | ||
| const physicalIdentity = kittyKeyIdentity(e); | ||
| const identity = kittyKeyIdentity(releaseEvent); | ||
| const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(identity); | ||
| const win32LookupIdentity = | ||
| aliasedReleaseIdentity !== undefined ? physicalIdentity : identity; | ||
| const hasForwardedWin32KeyDown = win32InputModeForwardedKeys.delete(win32LookupIdentity); | ||
| if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending(); | ||
| if (term.modes.win32InputMode) { | ||
| // Broadcast peers may still need a paired Kitty release for a keydown | ||
| // consumed by a Netcatty action (notably the urgent Ctrl+C path). | ||
| releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent)); | ||
| kittyForwardedKeys.delete(identity); | ||
| releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent), aliasedReleaseIdentity); | ||
| kittyForwardedKeys.delete(win32LookupIdentity); | ||
| // Only let xterm emit a Win32 key-up when its matching keydown was | ||
| // previously handed to xterm. Netcatty shortcuts, sudo controls and | ||
| // autocomplete consume their keydown and must not leak an orphaned | ||
|
|
@@ -1792,7 +1857,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime | |
| }; | ||
| return true; | ||
| } | ||
| if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent))) { | ||
| if (releaseForwardedKittyPress(toKittyKeyboardEvent(releaseEvent), aliasedReleaseIdentity)) { | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| return false; | ||
|
|
@@ -1981,10 +2046,28 @@ 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: ⌘. interrupts the running command like | ||
| // Ctrl+C (#3408). Only when nothing is selected so copy wins first. A | ||
| // user-assigned snippet or configured shortcut on this chord keeps | ||
| // precedence: the editors accept ⌘. (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 = | ||
| !hasCopyableSelection | ||
| && 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) { | ||
|
Comment on lines
+2061
to
+2070
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On macOS, a user can still assign Useful? React with 👍 / 👎. |
||
| const id = ctx.sessionRef.current; | ||
| if (id && ctx.statusRef.current === "connected") { | ||
| const rendererKeyAt = Date.now(); | ||
|
|
@@ -2035,32 +2118,84 @@ 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 | ||
| // ⌘. 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", | ||
|
Comment on lines
+2128
to
+2129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When macOS uses a non-QWERTY layout (for example, Dvorak), assigning the synthesized event Useful? React with 👍 / 👎. |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Kitty event reporting is enabled locally or on a broadcast peer, this records the normalized press under Useful? React with 👍 / 👎. |
||
| // The normalized press shares the Ctrl+C event identity (KeyC) with a | ||
| // possibly outstanding physical KeyC press; record it under a dedicated | ||
| // map key so the Period keyup releases (and deletes) only the | ||
| // interrupt press instead of the held physical key's entry (#3409). | ||
| const pressIdentity = | ||
| macCommandPeriodInterrupt && identity !== kittyKeyIdentity(e) | ||
| ? kittyNormalizedPressIdentity(identity) | ||
| : identity; | ||
| if (pressIdentity !== identity) { | ||
| // The physical release will arrive under the Period identity 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 && | ||
| shouldTrackKittyKeyRelease(kittyKeyboardMode, kittyEvent) | ||
| ) { | ||
| 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 +2204,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 +2610,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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the source terminal has negotiated Win32 input mode, broadcasting is enabled, and a physical
KeyCremains held while Command-period is pressed, the Period keyup is rewritten here to the normalizedKeyCevent. The subsequentwin32InputModeForwardedKeys.delete(identity)therefore consumes the outstanding physical C press and returnstrue, causing a premature native C release while the eventual real C keyup is dropped. Fresh evidence after the source-map alias fix is that the separatewin32InputModeForwardedKeyslookup still uses the rewritten identity; preserve the physicaleidentity for that lookup and suppress the aliased interrupt's native keyup.Useful? React with 👍 / 👎.