mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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,
|
||||
|
||||
+130
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user