fix(terminal): preserve Option-composed text in kitty panes

Adapt the composition fix from #20579 and the input-source correction from
#20164. Extend coverage to every Polish letter, live setting changes,
associated text, and Chromium-to-PTY word entry.

Co-authored-by: yu.xia <yuxianice@163.com>
Co-authored-by: Alexandre Blause <alexandre.blause@gmail.com>
This commit is contained in:
Neil
2026-09-16 13:06:05 -07:00
co-authored by yu.xia Alexandre Blause
parent e6a41081a3
commit 23fdb416d2
8 changed files with 208 additions and 119 deletions
@@ -47,20 +47,20 @@ function createRelease(flags: number): TerminalOptionKittyRelease | undefined {
return (flags & KITTY_REPORT_EVENT_TYPES) === 0 ? undefined : { flags }
}
// Why ASCII-only: the protocol says a text-producing key sends its text, but #8031 needs Option
// hotkeys to still reach kitty TUIs. ASCII splits the two — layouts hide `@ $ # [ ] { } \ |` behind
// Option with no other way to type them, while the glyphs on TUI-bound keys (π, ƒ, ∫) never are.
function isLayoutComposedAsciiCharacter(
// Compose-side text must survive kitty negotiation; Alt-configured sides still send chords.
function isLayoutComposedCharacter(
key: string,
characterWithoutOption: string | undefined
): boolean {
if (key.length !== 1) {
const chars = Array.from(key)
if (chars.length !== 1) {
return false
}
const codePoint = key.codePointAt(0) as number
const codePoint = chars[0].codePointAt(0)
return (
codePoint !== undefined &&
codePoint > 0x20 &&
codePoint <= 0x7e &&
!(codePoint >= 0x7f && codePoint <= 0x9f) &&
(characterWithoutOption === undefined ||
key.toLowerCase() !== characterWithoutOption.toLowerCase())
)
@@ -131,7 +131,7 @@ export function resolveTerminalOptionShortcutAction(
!kittyReportsAllKeysAsEscapeCodes(flags) &&
canSendComposedText &&
!isNumpad &&
isLayoutComposedAsciiCharacter(event.key, characterWithoutOption)
isLayoutComposedCharacter(event.key, characterWithoutOption)
) {
return { type: 'sendInput', data: event.key, optionKittyRelease: createRelease(flags) }
}
@@ -136,18 +136,73 @@ describe('Option-composed characters in kitty keyboard panes', () => {
).toBeNull()
})
it('still reports non-ASCII Option chords as kitty CSI-u hotkeys', () => {
// #8031: compose layouts must keep reaching TUI Option hotkeys, and every
// glyph those layouts compose on a bound key is non-ASCII.
it('types non-ASCII composed characters instead of reporting chords on a compose side (#20171)', () => {
expect(resolveKitty(event({ key: 'ƒ', code: 'KeyF', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[102;3u'
data: 'ƒ'
})
expect(resolveKitty(event({ key: '∫', code: 'KeyB', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[98;3u'
data: ''
})
expect(resolveKitty(event({ key: 'å', code: 'KeyA', altKey: true }))).toEqual({
type: 'sendInput',
data: 'å'
})
})
it.each([1, 5])('types every Polish letter and uppercase form under kitty flags %s', (flags) => {
const letters = [
['a', 'ą'],
['c', 'ć'],
['e', 'ę'],
['l', 'ł'],
['n', 'ń'],
['o', 'ó'],
['s', 'ś'],
['x', 'ź'],
['z', 'ż']
]
for (const [base, composed] of letters) {
for (const shiftKey of [false, true]) {
const key = shiftKey ? composed.toUpperCase() : composed
const code = `Key${base.toUpperCase()}`
const layout = (candidate: string, shifted: boolean): string | undefined =>
candidate === code ? (shifted ? base.toUpperCase() : base) : undefined
for (const [mode, side] of [
['false', 0],
['left', 2],
['right', 1]
] as const) {
expect(
resolveKitty(event({ key, code, altKey: true, shiftKey }), mode, side, layout, flags)
).toEqual({ type: 'sendInput', data: key })
}
for (const [mode, side] of [
['true', 0],
['left', 1],
['right', 2]
] as const) {
expect(
resolveKitty(event({ key, code, altKey: true, shiftKey }), mode, side, layout, flags)
).toEqual({
type: 'sendInput',
data: `\x1b[${base.codePointAt(0)}${flags === 5 && shiftKey ? `:${base.toUpperCase().codePointAt(0)}` : ''};${shiftKey ? 4 : 3}u`
})
}
}
}
})
it('counts supplementary-plane compositions as one character, not a chord', () => {
expect(resolveKitty(event({ key: '𝕒', code: 'KeyA', altKey: true }))).toEqual({
type: 'sendInput',
data: '𝕒'
})
})
it('still reports non-ASCII chords when the user explicitly configures Option as Alt', () => {
expect(resolveKitty(event({ key: 'å', code: 'KeyA', altKey: true }), 'true', 0)).toEqual({
type: 'sendInput',
data: '\x1b[97;3u'
})
@@ -759,34 +759,33 @@ describe('kitty keyboard protocol panes', () => {
active
)
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).
it('types Option-composed letters on a compose side instead of chords (#20171)', () => {
// Compose layouts need their letters; TUI hotkeys remain available on configured Alt sides.
expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
data: 'π'
})
expect(resolveKitty(event({ key: 'µ', code: 'KeyM', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[109;3u'
data: 'µ'
})
})
it('includes shift in the kitty modifier field', () => {
it('types shifted compositions on a compose side instead of chords', () => {
expect(resolveKitty(event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[112;4u'
data: ''
})
})
it('encodes Option+digit, punctuation, and configured Alt', () => {
it('types composed digits and punctuation; configured Alt keeps chords', () => {
expect(resolveKitty(event({ key: '¡', code: 'Digit1', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[49;3u'
data: '¡'
})
expect(resolveKitty(event({ key: '≥', code: 'Period', altKey: true }))).toEqual({
type: 'sendInput',
data: '\x1b[46;3u'
data: ''
})
expect(resolveKitty(event({ key: 'p', code: 'KeyP', altKey: true }), 'true')).toEqual({
type: 'sendInput',
@@ -828,12 +827,11 @@ describe('kitty keyboard protocol panes', () => {
).toEqual({ type: 'sendInput', data: '\x1bb' })
})
it('encodes the compose-side Option key as kitty CSI-u in left/right modes', () => {
// In 'left' mode the right Option normally composes; a kitty pane asked
// for modifier-accurate keys, so it gets alt-encoded too.
it('types on the compose-side Option; the Alt side keeps CSI-u in left/right modes', () => {
// In 'left' mode the right Option composes and now types its text (#20171).
expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 2)).toEqual({
type: 'sendInput',
data: '\x1b[108;3u'
data: '¬'
})
// The designated meta side upgrades from legacy Esc+letter to CSI-u.
expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 1)).toEqual({
@@ -886,23 +884,23 @@ describe('kitty keyboard protocol panes', () => {
layoutCharacterForCode
)
// AZERTY types M at the physical Semicolon position; the layout map must
// win over the US punctuation table so the chord reports alt+m, not alt+;.
// AZERTY types M at the physical Semicolon position; the layout map must win over the US
// punctuation table so an uncomposed press still reports alt+m, not alt+;.
const azerty = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined)
expect(resolveWithLayout(event({ key: 'µ', code: 'Semicolon', altKey: true }), azerty)).toEqual(
expect(resolveWithLayout(event({ key: 'm', code: 'Semicolon', altKey: true }), azerty)).toEqual(
{ type: 'sendInput', data: '\x1b[109;3u' }
)
// Colemak types P at the physical KeyR position.
const colemak = (code: string): string | undefined => (code === 'KeyR' ? 'p' : undefined)
expect(resolveWithLayout(event({ key: 'π', code: 'KeyR', altKey: true }), colemak)).toEqual({
expect(resolveWithLayout(event({ key: 'p', code: 'KeyR', altKey: true }), colemak)).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
})
// Falls back to the US table when the layout map has no entry.
const empty = (): string | undefined => undefined
expect(resolveWithLayout(event({ key: 'π', code: 'KeyP', altKey: true }), empty)).toEqual({
expect(resolveWithLayout(event({ key: 'p', code: 'KeyP', altKey: true }), empty)).toEqual({
type: 'sendInput',
data: '\x1b[112;3u'
})
@@ -10,13 +10,12 @@
* The only defensible default is the one that varies per layout. This
* module fingerprints the active layout from Chromium's
* navigator.keyboard.getLayoutMap() (ships in Chrome 69+, so every Electron
* we could run). We match Ghostty's taxonomy: US / US-International map to
* `true`; everything else — including Dvorak, Colemak, UK, every
* international layout — maps to `false`.
* we could run). The base layer cannot separate US from US-International or
* ABC, so every US-shaped layout maps to `true` here and `input-source-id.ts`
* narrows that to plain US whenever macOS gives us the real input source ID.
* Everything else — Dvorak, Colemak, UK, every international layout — maps
* to `false`.
*
* Reference implementation in Ghostty:
* ~/projects/ghostty/src/input/keyboard.zig:25-57 (Layout enum + detectOptionAsAlt)
* ~/projects/ghostty/macos/Sources/Helpers/KeyboardLayout.swift (Carbon probe)
*/
/** Minimal shape of the `KeyboardLayoutMap` we consume, so callers can stub
@@ -27,14 +26,13 @@ export type LayoutMapLike = {
}
export type DetectedLayoutCategory =
/** US Standard or US-International. Default → `'true'` (Option = Alt). */
/** US-shaped base layer; a native input-source override may still require composition. */
| 'us'
/** Any other recognized layout (UK, German, Turkish, French, Dvorak, etc.).
* Default → `'false'` (Option composes layout characters). */
| 'non-us'
/** API unavailable, empty map, or fingerprint incomplete. Default →
* `'false'` — the conservative safe choice, matching Ghostty's
* `.unknown => .false`. */
* `'false'` so Option remains available for composition. */
| 'unknown'
/**
@@ -60,8 +58,8 @@ export type DetectedLayoutCategory =
* Colemak passes KeyQ/W/A/Z/Quote/Backquote/BracketLeft/BracketRight but fails
* Semicolon (`o` vs `;`). Dvorak fails KeyQ immediately. Both get classified
* as `non-us` and default to `'false'`; users who want `'true'` flip the
* explicit override. Matches Ghostty (Ghostty only whitelists
* com.apple.keylayout.US and com.apple.keylayout.USInternational).
* explicit override. The native input-source classifier distinguishes
* plain US from US-shaped composition layouts.
*/
const US_FINGERPRINT: Record<string, string> = {
KeyQ: 'q',
@@ -12,8 +12,8 @@ describe('classifyInputSourceId', () => {
expect(classifyInputSourceId('com.apple.keylayout.US')).toBe('meta')
})
it('allowlists US International PC as meta', () => {
expect(classifyInputSourceId('com.apple.keylayout.USInternational-PC')).toBe('meta')
it('classifies US International PC as compose (Option+C → ç repro)', () => {
expect(classifyInputSourceId('com.apple.keylayout.USInternational-PC')).toBe('compose')
})
it('is case-insensitive on the allowlist (defaults differ between macOS versions)', () => {
@@ -38,9 +38,9 @@ describe('classifyInputSourceId', () => {
})
it('classifies every other Apple-shipped layout as compose (default-deny)', () => {
// Matches Ghostty: only US and USInternational-PC are allowlisted;
// everything else (Dvorak, Colemak, German, French, Turkish, Spanish,
// Swedish, every CJK Roman IME) falls back to compose.
// Only plain US is allowlisted; everything else (Dvorak, Colemak,
// German, French, Turkish, Spanish, Swedish, every CJK Roman IME)
// falls back to compose.
expect(classifyInputSourceId('com.apple.keylayout.Dvorak')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.Colemak')).toBe('compose')
expect(classifyInputSourceId('com.apple.keylayout.German')).toBe('compose')
@@ -1,47 +1,5 @@
/**
* Classifier for macOS's `AppleCurrentKeyboardLayoutInputSourceID`.
*
* Why this exists alongside detect-option-as-alt: the layout-fingerprint
* probe (`detectOptionAsAltFromLayoutMap`) inspects `navigator.keyboard
* .getLayoutMap()`, which only surfaces the base (unshifted) layer. Many
* macOS layouts keep a US-identical base layer but repurpose the Option
* layer for dead-key composition — ABC (Option+A = å), Polish Pro
* (Option+A = ą), US Extended, ABC Extended, and the CJK Roman IMEs all
* share this trap. The fingerprint classifies them as `'us'`, the
* effective setting resolves to `'true'`, xterm's `macOptionIsMeta`
* turns on, and every Option+letter keystroke is silently translated to
* an Esc+letter readline chord — so typing å, ą, ï, etc., fails with no
* visible feedback (issue #1205).
*
* The only layouts where Option-as-Meta is the right default are plain
* US Standard and US-International-PC — matching Ghostty's
* `detectOptionAsAlt` (~/projects/ghostty/src/input/keyboard.zig:25-57
* + ~/projects/ghostty/macos/Sources/Helpers/KeyboardLayout.swift,
* which whitelists only `com.apple.keylayout.US` and
* `com.apple.keylayout.USInternational-PC`).
*
* When the main-process IPC returns a non-null ID, this classifier is
* authoritative: `'meta'` → Option-as-Meta is safe; `'compose'` →
* Option must compose. The fingerprint probe is only consulted when no
* ID is available (non-Darwin, sandboxed defaults, IPC failure).
*/
/**
* Input source IDs where Option-as-Meta is the correct default. The
* shipped US Standard and US-International-PC layouts are the only
* Apple layouts that don't use Option for composition. Everything else
* — including ABC, Polish Pro, US Extended, ABC Extended, every
* international layout, every CJK Roman IME — composes via Option and
* must stay `'false'`.
*
* Matching is case-insensitive full-ID. We deliberately do NOT prefix-
* match here: `com.apple.keylayout.US` must not silently allowlist
* `com.apple.keylayout.USExtended`.
*/
const META_INPUT_SOURCE_IDS: readonly string[] = [
'com.apple.keylayout.us',
'com.apple.keylayout.usinternational-pc'
]
// The base-layer probe cannot distinguish US from layouts whose Option layer composes text.
const META_INPUT_SOURCE_IDS: readonly string[] = ['com.apple.keylayout.us']
export type InputSourceOverride =
/** Option-as-Meta is safe on this input source. Resolves to `'us'`
@@ -66,11 +24,6 @@ export function classifyInputSourceId(id: string | null | undefined): InputSourc
return 'meta'
}
}
// Why: any other macOS input source ID composes via Option. This
// includes ABC (not to be confused with US), Polish Pro, US Extended,
// ABC Extended, every international layout, Dvorak, Colemak, and
// every CJK Roman IME. Forcing `'compose'` matches Ghostty's
// allowlist-only behavior and prevents the #1205-style silent-swallow
// bug from recurring for any future Apple-shipped layout.
// International layouts need their Option composition layer, including US-International-PC.
return 'compose'
}
@@ -159,13 +159,7 @@ export function createOptionAsAltProbe(
return
}
// Why: when macOS returns a concrete input source ID, it's authoritative.
// The fingerprint can only see the base (unshifted) layer, which is
// US-identical on ABC, Polish Pro, US Extended, ABC Extended, and every
// CJK Roman IME — so trusting it flips macOptionIsMeta=true on all of
// them and silently swallows Option+letter compositions (#1205). The
// Only the two known Option-as-Meta layouts are allowed; every other
// concrete input source keeps Option available for composition.
// Native input-source identity distinguishes composition layouts with a US-shaped base layer.
const override = classifyInputSourceId(inputSourceId)
if (override === 'meta') {
notify('us')
+105 -14
View File
@@ -1,19 +1,10 @@
/**
* E2E for issue #14024: on a macOS layout that composes printable ASCII with
* Option (Turkish Q: Option+Q → `@`, Option+4 → `$`), a kitty-keyboard TUI must
* receive the composed character, not the physical Alt chord.
*
* The regression only exists once the pane's application has negotiated the
* kitty keyboard protocol, so the kitty flags are armed the way a real TUI arms
* them — by emitting `CSI > 1 u` from the PTY — rather than by poking renderer
* state. Bytes are asserted at the main-process `pty:write` boundary so the test
* proves what actually leaves the renderer.
*/
// Option composition must survive kitty negotiation (#14024, #20171, #20850).
import { test, expect } from './helpers/orca-app'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import {
execInTerminal,
waitForTerminalOutput,
waitForActiveTerminalManager,
waitForActivePanePtyId
} from './helpers/terminal'
@@ -155,7 +146,7 @@ async function setUpPane(
return { ptyId, joinedWrites: async () => (await getPtyWrites(app)).join('') }
}
test.describe('Option-composed ASCII in a kitty-keyboard pane', () => {
test.describe('Option-composed text in a kitty-keyboard pane', () => {
test.skip(process.platform !== 'darwin', 'Option composition is a macOS-only input path (#14024)')
test('types the composed character instead of reporting the physical Alt chord', async ({
@@ -223,9 +214,12 @@ test.describe('Option-composed ASCII in a kitty-keyboard pane', () => {
expect(await joinedWrites()).not.toContain('@')
})
test('keeps non-ASCII Option glyphs as TUI hotkeys', async ({ orcaPage, electronApp }) => {
test('keeps non-ASCII Option glyphs as TUI hotkeys when configured as Alt', async ({
orcaPage,
electronApp
}) => {
const { joinedWrites } = await setUpPane(orcaPage, electronApp)
await setMacOptionAsAlt(orcaPage, 'false')
await setMacOptionAsAlt(orcaPage, 'true')
await clearPtyWriteLog(electronApp)
// #8031: OMP-class TUIs bind Option+P, which composes the non-ASCII `π`.
@@ -240,4 +234,101 @@ test.describe('Option-composed ASCII in a kitty-keyboard pane', () => {
.toContain('\x1b[112;3u')
expect(await joinedWrites()).not.toContain('π')
})
test('types all Polish letters once under Claude flags and updates the mounted pane setting', async ({
orcaPage,
electronApp
}) => {
const { ptyId, joinedWrites } = await setUpPane(orcaPage, electronApp)
await execInTerminal(orcaPage, ptyId, `printf '\\033[<u\\033[>5u'`)
await expect.poll(() => getPaneKittyKeyboardFlags(orcaPage)).toBe(5)
await setMacOptionAsAlt(orcaPage, 'false')
await clearPtyWriteLog(electronApp)
const letters = [
['a', 'ą'],
['c', 'ć'],
['e', 'ę'],
['l', 'ł'],
['n', 'ń'],
['o', 'ó'],
['s', 'ś'],
['x', 'ź'],
['z', 'ż']
]
for (const [base, key] of letters) {
await pressOptionComposedKey(orcaPage, { key, code: `Key${base.toUpperCase()}` })
await pressOptionComposedKey(orcaPage, {
key: key.toUpperCase(),
code: `Key${base.toUpperCase()}`,
shiftKey: true
})
}
await expect.poll(joinedWrites).toBe('ąĄćĆęĘłŁńŃóÓśŚźŹżŻ')
await setMacOptionAsAlt(orcaPage, 'true')
await clearPtyWriteLog(electronApp)
await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' })
await expect.poll(joinedWrites).toBe('\x1b[97;3u')
await setMacOptionAsAlt(orcaPage, 'false')
await clearPtyWriteLog(electronApp)
await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' })
await expect.poll(joinedWrites).toBe('ą')
})
test('reports Polish associated text once under report-all flags', async ({
orcaPage,
electronApp
}) => {
const { ptyId, joinedWrites } = await setUpPane(orcaPage, electronApp)
await execInTerminal(orcaPage, ptyId, `printf '\\033[<u\\033[>29u'`)
await expect.poll(() => getPaneKittyKeyboardFlags(orcaPage)).toBe(29)
await setMacOptionAsAlt(orcaPage, 'false')
await clearPtyWriteLog(electronApp)
await pressOptionComposedKey(orcaPage, { key: 'ą', code: 'KeyA' })
await expect.poll(joinedWrites).toBe('\x1b[97;3;261u')
})
test('renders Polish words entered through Chromium keyboard events', async ({
orcaPage,
electronApp
}, testInfo) => {
const { ptyId, joinedWrites } = await setUpPane(orcaPage, electronApp)
await execInTerminal(orcaPage, ptyId, `printf '\\033[<u\\033[>5u'; cat`)
await expect.poll(() => getPaneKittyKeyboardFlags(orcaPage)).toBe(5)
await setMacOptionAsAlt(orcaPage, 'false')
await clearPtyWriteLog(electronApp)
const cdp = await orcaPage.context().newCDPSession(orcaPage)
const bases: Record<string, string> = {
ą: 'a',
ć: 'c',
ę: 'e',
ł: 'l',
ń: 'n',
ó: 'o',
ś: 's',
ź: 'x',
ż: 'z'
}
const phrase = 'zażółć wcześniej łącznie'
try {
for (const key of phrase) {
const base = bases[key] ?? key
const code = key === ' ' ? 'Space' : `Key${base.toUpperCase()}`
const modifiers = bases[key] ? 1 : 0
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key,
code,
modifiers,
text: key,
unmodifiedText: base
})
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, modifiers })
}
await expect.poll(joinedWrites).toBe(phrase)
await waitForTerminalOutput(orcaPage, phrase)
await orcaPage.screenshot({ path: testInfo.outputPath('polish-words.png') })
} finally {
await cdp.detach()
}
})
})