fix(terminal): honor Option compose under kitty keyboard panes

When vim/Pi enable the kitty keyboard protocol, Orca was CSI-u-encoding
every Option chord even with Option as Alt Off, so French-PC `{` and
German `@` became Meta sequences. Honor explicit compose-side modes with
literal input, keep auto letter CSI-u for TUI hotkeys, and always pass
through composed ASCII punctuation.

Based on @rodboev's approach in #8824; also covers #8399.

Closes #8733
Closes #8399
This commit is contained in:
Neil
2026-07-16 17:41:33 -07:00
parent 1cdc8e85b3
commit abe9447c42
5 changed files with 233 additions and 24 deletions
@@ -1014,6 +1014,12 @@ export default function TerminalPane({
const effectiveMacOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt)
const macOptionAsAltRef = useRef<MacOptionAsAlt>(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'
@@ -49,7 +49,8 @@ export function resolveTerminalKeyboardShortcutAction(
isKittyKeyboardActivePane: Parameters<typeof resolveTerminalShortcutAction>[7],
layoutBaseCharacterForCode: Parameters<typeof resolveTerminalShortcutAction>[8],
getWindowsShiftEnterEncoding: Parameters<typeof resolveTerminalShortcutAction>[9],
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>
isWindowsTerminalHost: NonNullable<Parameters<typeof resolveTerminalShortcutAction>[10]>,
optionAsAltIsExplicit: Parameters<typeof resolveTerminalShortcutAction>[11] = false
): ReturnType<typeof resolveTerminalShortcutAction> {
// 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<boolean>
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
optionAsAltIsExplicitRef: React.RefObject<boolean>
paneKittyKeyboardModesRef?: React.RefObject<Map<number, TerminalKittyKeyboardModeTracker>>
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,
@@ -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<typeof resolveTerminalShortcutAction>[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<typeof resolveTerminalShortcutAction>[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)
})
})
@@ -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).
@@ -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.