diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 8bccda3aa61..a6836fb9648 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1014,6 +1014,12 @@ export default function TerminalPane({ const effectiveMacOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt) const macOptionAsAltRef = useRef(effectiveMacOptionAsAlt) macOptionAsAltRef.current = effectiveMacOptionAsAlt + // Why: policy must distinguish explicit Off/left/right from auto-resolved + // Off so kitty panes keep TUI Option hotkeys under auto while honoring an + // intentional compose choice (issues #8733 / #8399). + const optionAsAltIsExplicit = (settings?.terminalMacOptionAsAlt ?? 'auto') !== 'auto' + const optionAsAltIsExplicitRef = useRef(optionAsAltIsExplicit) + optionAsAltIsExplicitRef.current = optionAsAltIsExplicit const onPtyExitRef = useRef(onPtyExit) onPtyExitRef.current = onPtyExit @@ -1849,6 +1855,7 @@ export default function TerminalPane({ searchOpenRef, searchStateRef, macOptionAsAltRef, + optionAsAltIsExplicitRef, paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first' diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 61a092fd56f..14a5ce3b9e1 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -49,7 +49,8 @@ export function resolveTerminalKeyboardShortcutAction( isKittyKeyboardActivePane: Parameters[7], layoutBaseCharacterForCode: Parameters[8], getWindowsShiftEnterEncoding: Parameters[9], - isWindowsTerminalHost: NonNullable[10]> + isWindowsTerminalHost: NonNullable[10]>, + optionAsAltIsExplicit: Parameters[11] = false ): ReturnType { // Why: keep the host callback required at the production boundary so a // caller cannot silently fall back to client-OS byte routing. @@ -64,7 +65,8 @@ export function resolveTerminalKeyboardShortcutAction( isKittyKeyboardActivePane, layoutBaseCharacterForCode, getWindowsShiftEnterEncoding, - isWindowsTerminalHost + isWindowsTerminalHost, + optionAsAltIsExplicit ) } @@ -201,6 +203,7 @@ type KeyboardHandlersDeps = { searchOpenRef: React.RefObject searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject + optionAsAltIsExplicitRef: React.RefObject paneKittyKeyboardModesRef?: React.RefObject> keybindings?: KeybindingOverrides terminalShortcutPolicy?: TerminalShortcutPolicy @@ -236,6 +239,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + optionAsAltIsExplicitRef, paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy = 'orca-first' @@ -392,7 +396,8 @@ export function useTerminalKeyboardShortcuts({ isKittyKeyboardActivePane, getLayoutBaseCharacterForCode, getActivePaneWindowsShiftEnterEncoding, - isActivePaneWindowsTerminalHost + isActivePaneWindowsTerminalHost, + optionAsAltIsExplicitRef.current ) if (!action) { return @@ -637,6 +642,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + optionAsAltIsExplicitRef, paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy, diff --git a/src/renderer/src/components/terminal-pane/repro-8733-8399-kitty-option-compose.test.ts b/src/renderer/src/components/terminal-pane/repro-8733-8399-kitty-option-compose.test.ts new file mode 100644 index 00000000000..10d96d9f37b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/repro-8733-8399-kitty-option-compose.test.ts @@ -0,0 +1,130 @@ +/** + * Issues #8733 + #8399 — Option composition under kitty keyboard protocol. + * + * #8733: French-PC, Option as Alt = Off. Shell composes `{` via AltGr; after + * vim enables kitty protocol, Option chords must stay composed text (not + * CSI-u Meta). Reload-without-restarting-vim previously appeared to "fix" + * because SerializeAddon does not persist kitty flags. + * + * #8399: German layout Option+L → `@`. Works in bare shell; Pi enables kitty + * and must still receive `@` rather than physical L as `\x1b[108;3u`. + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/terminal-pane/repro-8733-8399-kitty-option-compose.test.ts + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' + +function event(partial: { + key: string + code?: string + altKey?: boolean + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean +}): Parameters[0] { + return { + key: partial.key, + code: partial.code, + altKey: partial.altKey ?? false, + shiftKey: partial.shiftKey ?? false, + metaKey: partial.metaKey ?? false, + ctrlKey: partial.ctrlKey ?? false, + repeat: false + } +} + +const kittyActive = (): boolean => true +const kittyInactive = (): boolean => false + +function resolve( + input: Parameters[0], + macOptionAsAlt: 'true' | 'false' | 'left' | 'right' = 'false', + optionKeyLocation = 0, + active: () => boolean = kittyActive, + optionAsAltIsExplicit = false +) { + return resolveTerminalShortcutAction( + input, + true, + macOptionAsAlt, + optionKeyLocation, + false, + undefined, + undefined, + active, + undefined, + undefined, + undefined, + optionAsAltIsExplicit + ) +} + +describe('issue #8733/#8399 kitty Option composition', () => { + it('sends German Option+L as @ under auto Off + kitty (Pi)', () => { + expect(resolve(event({ key: '@', code: 'KeyL', altKey: true }))).toEqual({ + type: 'sendInput', + data: '@' + }) + }) + + it('sends French AltGr-style Option+quote as { under auto Off + kitty (vim)', () => { + expect(resolve(event({ key: '{', code: 'Quote', altKey: true }), 'false', 2)).toEqual({ + type: 'sendInput', + data: '{' + }) + }) + + it('sends composed punctuation for explicit Option Off under kitty', () => { + expect( + resolve(event({ key: '{', code: 'Quote', altKey: true }), 'false', 0, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '{' }) + expect( + resolve(event({ key: '@', code: 'KeyL', altKey: true }), 'false', 0, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '@' }) + }) + + it('with macOptionAsAlt=false + no kitty, Option+L is left for composition', () => { + expect( + resolve(event({ key: '@', code: 'KeyL', altKey: true }), 'false', 0, kittyInactive) + ).toBeNull() + }) + + it('keeps Option+letter CSI-u encoding for TUI hotkeys under auto Off + kitty', () => { + expect(resolve(event({ key: 'π', code: 'KeyP', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + }) + + it('honors explicit left/right Meta vs compose under kitty', () => { + expect( + resolve(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 1, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '\x1b[108;3u' }) + expect( + resolve(event({ key: '{', code: 'Quote', altKey: true }), 'left', 2, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '{' }) + expect( + resolve( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'right', + 1, + kittyActive, + true + ) + ).toEqual({ type: 'sendInput', data: '∏' }) + }) + + it('documents that SerializeAddon path does not serialize kitty flags (reload clears tracker)', () => { + const tracker = readFileSync( + join(__dirname, '../../../../shared/terminal-kitty-keyboard-mode-tracker.ts'), + 'utf8' + ) + // Design comment: xterm SerializeAddon does not serialize kitty flags — + // reload loses protocol state until the TUI re-pushes (matches #8733). + expect(tracker).toMatch(/SerializeAddon does not serialize kitty/i) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 2cfe267669c..7872b971e7a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -684,7 +684,8 @@ describe('kitty keyboard protocol panes', () => { input: TerminalShortcutEvent, macOptionAsAlt: 'true' | 'false' | 'left' | 'right' = 'false', optionKeyLocation = 0, - active: () => boolean = kittyActive + active: () => boolean = kittyActive, + optionAsAltIsExplicit = false ) => resolveTerminalShortcutAction( input, @@ -694,9 +695,36 @@ describe('kitty keyboard protocol panes', () => { false, undefined, undefined, - active + active, + undefined, + undefined, + undefined, + optionAsAltIsExplicit ) + // Explicit Off + auto ASCII punctuation: see repro-8733-8399-kitty-option-compose.test.ts + it('lets explicit Option Off compose in kitty panes and keeps auto letter CSI-u', () => { + expect( + resolveKitty(event({ key: '{', code: 'Quote', altKey: true }), 'false', 0, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '{' }) + expect( + resolveKitty(event({ key: '∫', code: 'KeyB', altKey: true }), 'false', 0, kittyActive, true) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + expect(resolveKitty(event({ key: '@', code: 'KeyL', altKey: true }))).toEqual({ + type: 'sendInput', + data: '@' + }) + expect( + resolveKitty( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'left', + 2, + kittyActive, + true + ) + ).toEqual({ type: 'sendInput', data: '∏' }) + }) + it('encodes Option+letter as kitty CSI-u with the physical base key in compose mode', () => { // macOS composition reports key='π' for Option+P on ABC/compose layouts; // OMP binds alt+p (temporary model) and alt+m (model selector). diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index c6d7fed2afd..305235934f6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -48,6 +48,20 @@ function kittyAltModifiers(shiftKey: boolean): number { return shiftKey ? 4 : 3 } +/** + * True for printable ASCII that is not a letter or digit (e.g. `@`, `{`, `[`). + * Why: international layouts compose these via Option/AltGr. Under auto-resolved + * Option-as-Alt Off we still CSI-u encode Option+letter glyphs (π, µ) so TUI + * hotkeys like OMP Alt+P work, but essential ASCII punctuation must stay text. + */ +function isComposedAsciiPunctuation(key: string): boolean { + if (key.length !== 1) { + return false + } + const code = key.charCodeAt(0) + return code >= 33 && code <= 126 && !/[a-zA-Z0-9]/.test(key) +} + /** The un-shifted ASCII character for a physical key code (letters, digits, * and the punctuation map above), or undefined for unmapped codes. */ function resolveUnshiftedCharacterForCode(code: string | undefined): string | undefined { @@ -93,7 +107,12 @@ export function resolveTerminalShortcutAction( getWindowsShiftEnterEncoding?: () => WindowsShiftEnterEncoding, // Why: keybindings follow the client OS, but terminal byte protocols follow // the PTY host. They differ for macOS clients attached to Windows runtimes. - isWindowsTerminalHost: () => boolean = () => isWindows + isWindowsTerminalHost: () => boolean = () => isWindows, + // Why: 'auto' resolves to the same four modes as an explicit pick, but an + // explicit Off/left/right choice must win over kitty physical-key encoding + // so compose layouts keep punctuation (French-PC `{`, German `@`). Auto + // keeps letter CSI-u for TUI hotkeys and only special-cases ASCII punctuation. + optionAsAltIsExplicit = false ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -290,18 +309,38 @@ export function resolveTerminalShortcutAction( // // The handling depends on the macOptionAsAlt setting (mirrors Ghostty): // - 'true': xterm handles all Option as Meta natively; nothing to do here. - // - kitty-protocol pane (any other mode): the TUI asked for modifier-accurate - // keys, so every Option chord is encoded as kitty CSI-u with the physical - // base key (Option+P → \x1b[112;3u). Without this, xterm's kitty encoder - // reports the composed codepoint (alt+π), which no TUI binds — the chord - // neither triggers the hotkey nor types the character (issue: OMP Alt+P / - // Alt+M dead on compose layouts). Dead keys are exempt so composition - // (Option+E → ´) keeps working. + // - kitty-protocol pane: auto-resolved compose mode and explicit Meta-side + // modes encode Option+letter as kitty CSI-u with the physical base key + // (Option+P → \x1b[112;3u) so TUI hotkeys work on compose layouts. An + // explicitly chosen compose side keeps literal composed input instead. + // Auto still keeps ASCII punctuation (German `@`, French `{`) as text. + // Dead keys are exempt so composition (Option+E → ´) keeps working. // - 'false': compensate the three most critical readline shortcuts (B/F/D). // - 'left'/'right': the designated Option key acts as full Meta (emit Esc+ // for any single letter); the other key composes, with B/F/D compensated. if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && macOptionAsAlt !== 'true') { - if (event.key !== 'Dead' && isKittyKeyboardActivePane?.()) { + // Why: event.location on a character key reports that key's position + // (always 0 for standard keys), NOT which modifier is held. The caller + // must track the Option key's own keydown location and pass it as + // optionKeyLocation. + const isLeftOption = optionKeyLocation === 1 + const isRightOption = optionKeyLocation === 2 + const shouldActAsMeta = + (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) + const kittyActive = event.key !== 'Dead' && isKittyKeyboardActivePane?.() === true + const explicitComposeSide = optionAsAltIsExplicit && !shouldActAsMeta + const needsReadlineComposeCompensation = + !shouldActAsMeta && + !event.shiftKey && + (event.code === 'KeyB' || event.code === 'KeyF' || event.code === 'KeyD') + + if (kittyActive && !explicitComposeSide) { + // Why: auto-resolved Off still CSI-u-encodes Option+letter for OMP-style + // hotkeys, but layout-essential ASCII punctuation must remain text even + // when the TUI enabled kitty (issues #8733 French `{`, #8399 German `@`). + if (!shouldActAsMeta && isComposedAsciiPunctuation(event.key)) { + return { type: 'sendInput', data: event.key } + } const baseCharacter = (event.code ? layoutBaseCharacterForCode?.(event.code) : undefined) ?? resolveUnshiftedCharacterForCode(event.code) @@ -313,17 +352,16 @@ export function resolveTerminalShortcutAction( } } + if ( + kittyActive && + explicitComposeSide && + event.key.length === 1 && + !needsReadlineComposeCompensation + ) { + return { type: 'sendInput', data: event.key } + } + if (!event.shiftKey) { - // Why: event.location on a character key reports that key's position - // (always 0 for standard keys), NOT which modifier is held. The caller - // must track the Option key's own keydown location and pass it as - // optionKeyLocation. - const isLeftOption = optionKeyLocation === 1 - const isRightOption = optionKeyLocation === 2 - - const shouldActAsMeta = - (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) - if (shouldActAsMeta) { // Emit Esc+key (e.g. Option+B → \x1bb) for letters, digits, and // mapped punctuation.