From 94cc867e2a86f60fda03c72301e203b783ef1e4b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:04:36 -0700 Subject: [PATCH] feat(terminal): encode IME commits as CSI-u under the all-keys kitty flag (#13310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): show a preedit the IME resumes without a compositionstart Typing 2-Set Korean shows committed syllables but not the in-progress jamo, so the user composes each syllable blind. Long-standing hole in the vendored terminal library, not a regression: the same test fails identically against the bundle this branch starts from. The `.active` class that CSS keys `display: block` off is added only in `compositionstart` and dropped in `_finalizeComposition`. Some IMEs (observed on Windows/WSL Korean) resume a composition with a bare `compositionupdate` and no second `compositionstart`, by which point `compositionend` has already hidden the overlay, so the resumed preedit is written into a hidden element and never positioned. `updateCompositionElements` also early-returned on `!_isComposing`, so it would not lay the overlay out either. Re-show the overlay on an update that carries data, and key the layout guard on the shown overlay instead. `_isComposing` is deliberately left alone, so no commit bookkeeping changes and `onData` stays byte-identical. The two guards are equivalent on every pre-existing path: `compositionstart` sets both, `_finalizeComposition` clears both. The bundle hunks are the same two edits applied to the shipped minified output; the sourcemaps are carried through unchanged. * test(terminal): prove the resumed-preedit fix against a recorded Windows capture The synthetic test pins the shape; this replays events a real Microsoft Korean IME emitted on Windows/WSL. The capture holds three compositionupdates that resume a composition with no second compositionstart — the exact ordering that wrote the preedit into a hidden overlay. Without the fix all three report shown:false; with it all three are visible. Fixture derived from the sealed 11919-windows-wsl-current capture, which is read-only and unmodified. Co-authored-by: Orca * test(terminal): stop the recorded Hangul fixture pinning a derivation artifact The capture logs each event twice — a dispatch record and a batched next-frame re-log. Deriving from both replayed every event twice, which made three compositionupdates appear to land after a session had ended. Filtered to dispatch records the capture holds zero resumes and 11 balanced sessions, so the previous toHaveLength(3) was pinning an artifact of the derivation. Re-scoped to what the capture does prove: the preedit stays visible across all 37 real updates. Verified by reverting the patch that this passes either way, so it is coverage and the synthetic test remains the discriminator. Both facts are now stated in the file. Co-authored-by: Orca * fix(terminal): restore the preedit visibility patch onto its own branch The previous commit accidentally reverted it: checking main's patch and lockfile into the worktree to test whether a test discriminates also stages them, so the commit that followed swept them up. Co-authored-by: Orca * fix(terminal): claim printable keydowns structurally so committed text survives Co-authored-by: Orca * chore(reliability-gates): retarget the IME forwarding gate after the allowlist removal The gate listed terminal-ime-input-source.test.ts, which went with the input-source allowlist. Points at the substituted-text commit test instead, which covers what the gate is actually protecting: text committed outside a composition session reaching the pty exactly once. Co-authored-by: Orca * docs(terminal): record why withholding a claimed keydown needs no timer The predicate withholds a keydown's byte until the commit arrives, so a key the IME eats without committing would be dropped. Measured across the recorded corpus that case does not occur, and the browser marks IME-owned presses on the keydown itself. Both facts belong next to the predicate rather than only in a handoff note, since the obvious fix for the imagined gap is a timer, and a timer here once wrote a newline the user never typed. Co-authored-by: Orca * test(terminal): pin the kitty all-keys-as-escape-codes hole explicitly Flag 8 asks for every printable key as an escape code; this path sends the committed text raw instead. That is a deliberate trade, not an oversight, but it was untested — the suite only covered the disambiguate flag. Pinning it makes the choice visible and records the gate to use if it ever needs closing. Co-authored-by: Orca * fix(terminal): keep the kitty key-release report for presses that reached the pty Claiming the keyup unconditionally suppressed xterm's release report. That was sized for the old design, which claimed only a short punctuation list; the structural claim takes every printable keydown, so on macOS an app that negotiated kitty report_event_types stopped seeing releases for ordinary typing and would treat every printable key as held down. Suppress the release only when the press put nothing on the wire — swallowed by the input source, or owned by a composition transaction. xterm emits nothing from keyup unless kitty report_event_types (or win32 input mode) is on, so letting it through is inert everywhere else. Co-authored-by: Orca * test(e2e): assert IME preedit geometry headlessly for Korean and CJK input Both IME defects that shipped and were reverted walked through a suite of ~3000 passing assertions, because every one of them was about bytes reaching the PTY. A preedit rendered into a hidden overlay satisfies all of them while the user composes blind. The real-geometry coverage that would have caught it existed but was headful, env-gated and macOS-only, so it never ran in CI. Drives composition through CDP Input.imeSetComposition instead of a native input source, which removes the accessibility grant and the system input source that forced the headful gate. The suite runs in the normal headless project in about 55s serially, and asserts the composition overlay's real bounding rect — the one property an overlay clipped to max-width:0 cannot fake and a DOM emulator cannot produce. Three tests are red on main and marked test.fail() so they stay visible in CI and flip loud when their fix lands: the preedit resumed by a bare compositionupdate, and full-width punctuation and digits committed from a keydown that still carries the ASCII layout key. Co-authored-by: Orca * test(e2e): drop the known-broken markers now the stack closes all three Validated on real macOS hardware: with the two fixes below this layer, all three report "Expected to fail, but passed". Korean preedit renders at non-zero geometry through every jamo, and an Apple pinyin source sends ef bc 8c e3 80 82 to the pty where main sends ASCII. Worth recording why the punctuation case looked green on main once: an input source whose id happens to contain an allowlist term, as Sogou's does, satisfies the old gate. Correctness there depended on which IME the user had selected. Co-authored-by: Orca * test(e2e): cover the Linux and Windows IME ownership shapes headlessly The ten headless IME specs on this branch all decided ownership through a macOS user-agent override, so the two platforms whose failure mode is a *dropped* character rather than a downgraded one had no coverage at all, and the one Windows-recorded trace already in the suite was replayed under whichever policy the runner happened to report — macOS locally, Linux on the CI shards. Adds four Linux specs and two Windows specs, every one of them replaying a native capture rather than a hand-authored ordering: - IBus/X11 Hangul mixed with literal ASCII. Its `compositionend` is EMPTY and the syllable arrives afterwards as a bare `insertText`, so reading the commit off `compositionend.data` — which the Windows capture rewards — drops every syllable on this framework. - fcitx5/Wayland Hangul. No keydown at all for a composing key, not even 229, and physically wrong `code` values on the literal keys. Any ownership rule reading 229 or `code` fails here. - Numeric pinyin candidate selection under both frameworks, with the ordinary digit kept as the negative control, so the two directions are pinned against each other rather than separately. - Windows Microsoft Korean captured with real scan codes, including the two lines committed with Shift held. Each asserts both sides of the boundary: the preedit's real geometry at every frame the user would see, and the exact byte stream the native run put on the PTY. The recorded `onData` the Windows/WSL fixture already carried is now asserted instead of sitting unused. Chinese moves up to first-class alongside Korean: pinyin preedit width is now pinned the way the Japanese phrase already was, and full-width punctuation is covered in the composition-session shape the Windows and Linux frameworks use, not only the macOS insertText shape. Two harness fixes fell out of the recorded traces and are why the IBus one passes. The replay applied each event's recorded textarea state *after* dispatch, one event too late for the handlers that read `textarea.value`; and it left a task boundary between `compositionend` and the `input` carrying the commit, which Chromium never inserts, letting xterm's deferred finalizer settle against a textarea the committed text had not reached yet. Co-authored-by: Orca * test(e2e): replay the recorded macOS IME shapes instead of only synthesising them The macOS coverage on this branch drives Chromium composition through CDP, which is genuine but hand-ordered, so it could not assert the one property that decides the macOS rule: a composing keydown arrives with keyCode 229 while `key` is still the single translated character the input source produced — `ㅎ`, not `Process` — which is indistinguishable by length from an ordinary printable key. Three native captures were sitting unused in the evidence set. Adds a recorded 2-Set Korean session, including the syllable boundary where one composition closes and the next opens with no keydown between them, asserted against its own recorded byte stream. Adds the third failure mode, which had no coverage in any shape: an abandoned preedit leaking to the shell. Pinyin and Cangjie both backspace a composition away to nothing, and the assertion is not "the right bytes" but "no bytes". Both cancellation captures continue with a literal `ordinary` typed as bare keydowns, the recorder's own negative control. That tail carries no `input` events because the build it was captured on produced the byte from the keydown itself, so replaying it would measure the recorder rather than the product; the specs cut at the `compositionend` and say so. Co-authored-by: Orca * test(e2e): promote the non-allowlist input-source punctuation spec to the suite Qingg matches none of the terms the pre-structural build enumerated, so on that build its bypass never installs. It is the one arm no headless spec can express, and the only test here that the pre-structural build cannot pass. Promoted from scratch with four changes, each forced by a measurement rather than by taste: - A non-attached input method is a refusal, not a negative result. macOS attaches per app instance and the attach can simply fail — 3 of 6 instances under exclusive host access, and re-selecting the source did not recover one of them across 9 keystrokes. That is now `test.skip()` with a reason naming the rerun, not a thrown error, and the suite does not gate on a fully green session. - Attachment is probed with a LETTER. Punctuation substitution emits no compositionstart and no keyCode 229 even under a fully attached source, so at the keydown it is indistinguishable from having no source at all. The punctuation arms are judged on PTY bytes alone. - The ASCII-layout control is now part of the spec rather than a side experiment. Without it a build that rewrote every `.` into `。` unconditionally would pass the Qingg arm and be badly wrong. - The assertion runs by default instead of behind a strict-mode flag, and gained a non-vacuity check: the input source must have committed something. That is the sharp end of the mechanism — on the old build the DOM carries only keydown and keyup, so nothing is committed at all and the character is destroyed before the source is asked. The verdict stays an equality between two measurements, never a comparison against a hardcoded glyph, so it holds whatever punctuation mode the operator's input source happens to be in. It reads `beforeinput`, not `input`: the forwarder consumes `input` in the capture phase on the pane element, so a probe on the helper textarea never sees it and a strict run fails with correct bytes underneath. Co-authored-by: Orca * feat(terminal): encode IME commits as CSI-u under the all-keys kitty flag A pane that negotiates `report_all_keys_as_escape_codes` (bit 3) asked for every printable key as a CSI-u report. The commit path wrote IME-committed text raw, so such a pane got a legacy byte stream it had declined. That predicate has no IME-specific condition, so it affected every macOS user in such a pane, not just CJK users. Encode the press that produced the commit instead, reusing xterm's own kitty encoder rather than hand-rolling CSI-u. `claimKeyEvent` is untouched: still unconditional, still structural, still no kitty read on the keydown. The flag read happens once per commit. The gate is bit 3 alone. Flags 1/2/4/16 leave printable keys as text, so panes negotiating only those keep receiving substituted characters; gating on "kitty active" would strip the substitution from every pane that negotiates anything. Known limit, pinned by test: the report carries the physical key's codepoint, not the committed glyph. Bit 3 is the app declaring it does not want text, and bit 4 is how it asks for text back — but xterm's encoder derives that text field from the same `key` it derives the keycode from, so carrying the committed glyph needs an encoder change, not a wider gate. * fix(terminal): report a held key's repeats as REPEAT under the kitty flags The commit encoder never passed an event type, so xterm's encoder applied its PRESS default to every auto-repeat keydown. A pane negotiating report_event_types alongside bit 3 saw one held key as N separate strikes. Carry the keydown's `repeat` on the claimed press and map it to the protocol's REPEAT. The event type only reaches the wire when report_event_types is negotiated, so this is inert for panes that asked only for bit 3. Co-authored-by: Orca * chore: drop non-mergeable IME e2e scratch files --------- Co-authored-by: Orca --- .../terminal-ime-kitty-commit-encoding.ts | 76 +++++++++++++++++ ...terminal-ime-native-text-forwarder.test.ts | 84 +++++++++++++++++++ .../terminal-ime-native-text-forwarder.ts | 24 +++++- ...rminal-ime-substituted-text-commit.test.ts | 64 ++++++++++++-- .../use-terminal-pane-lifecycle.ts | 4 +- 5 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-kitty-commit-encoding.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-kitty-commit-encoding.ts b/src/renderer/src/components/terminal-pane/terminal-ime-kitty-commit-encoding.ts new file mode 100644 index 000000000..c47b77f53 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-kitty-commit-encoding.ts @@ -0,0 +1,76 @@ +// Reuses xterm's own kitty encoder rather than hand-rolling CSI-u. It lives in +// the package's `src/` tree and is absent from the public typings, so this is a +// deep import into a pinned dependency — acceptable here because the version is +// already pinned by a patch that would fail to apply across a bump. +import { KittyKeyboard } from '@xterm/xterm/src/common/input/KittyKeyboard' + +/** + * `report_all_keys_as_escape_codes`. Bit 3 is the only flag that changes what a + * plain printable key should put on the wire; 1/2/4/16 leave it as text. + */ +const KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b1000 + +/** + * `KittyKeyboardEventType.PRESS` / `.REPEAT`. Inlined because the upstream enum is a + * `const enum`, which does not survive an import across module boundaries. + */ +const KITTY_EVENT_TYPE_PRESS = 1 +const KITTY_EVENT_TYPE_REPEAT = 2 + +const kittyKeyboardEncoder = new KittyKeyboard() + +/** The physical keydown that produced a commit, captured before the input event. */ +export type ImeCommitKeyPress = { + key: string + code?: string + shiftKey: boolean + /** An auto-repeat keydown; the protocol distinguishes it from a fresh press. */ + repeat?: boolean +} + +/** + * A pane that negotiated bit 3 asked for every printable key as a CSI-u report, + * so writing IME-committed text raw hands it the legacy byte stream it declined. + * Re-encode the press that produced the commit instead. + * + * The gate is bit 3 ALONE. "Kitty is active" and `flags !== 0` are both wrong: + * a pane negotiating only disambiguation or event types still expects printable + * keys as text, and encoding there would drop every substituted character. + * + * Returns null when the commit should be written raw, which is every case except + * bit 3. + * + * Known limit: the report carries the *physical* key's codepoint, not the + * committed glyph — bit 3 is the app declaring it does not want text, and bit 4 + * (`report_associated_text`) is how it asks for text back. xterm's encoder + * derives that text field from the same `key` it derives the keycode from, so + * carrying the committed glyph under bit 4 needs an encoder change, not a flag. + */ +export function encodeImeCommitAsKittyReport( + press: ImeCommitKeyPress | null, + kittyKeyboardFlags: number +): string | null { + if ((kittyKeyboardFlags & KITTY_REPORT_ALL_KEYS_AS_ESCAPE_CODES) === 0 || !press) { + return null + } + // Why: the forwarder only claims presses with no control chord, so the + // modifier fields are known-false rather than read from a live event. + const encoded = kittyKeyboardEncoder.evaluate( + { + type: 'keydown', + key: press.key, + code: press.code ?? '', + keyCode: 0, + shiftKey: press.shiftKey, + altKey: false, + ctrlKey: false, + metaKey: false + }, + kittyKeyboardFlags, + // Why: a held key emits repeated keydowns, and the protocol reports those as REPEAT. + // Defaulting them all to PRESS would make one held key look like N separate strikes to + // an app that counts presses or filters repeats. + press.repeat === true ? KITTY_EVENT_TYPE_REPEAT : KITTY_EVENT_TYPE_PRESS + ) + return encoded.key ?? null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts index 11f9261f4..2894df2e2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts @@ -326,6 +326,90 @@ describe('installTerminalImeNativeTextForwarder', () => { }) }) + describe('the kitty read is scoped to the commit', () => { + function installWithFlags( + getKittyKeyboardFlags: () => number, + isComposing: () => boolean = () => false + ): { + forwarder: ReturnType + sendInput: ReturnType + } { + const sendInput = vi.fn() + const forwarder = installTerminalImeNativeTextForwarder({ + terminalElement: element, + isComposing, + sendInput, + getKittyKeyboardFlags + }) + return { forwarder, sendInput } + } + + it('never reads the flags on a keydown, only on the commit', () => { + const getKittyKeyboardFlags = vi.fn(() => 8) + const { forwarder } = installWithFlags(getKittyKeyboardFlags) + + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + forwarder.claimKeyEvent(keyEvent({ key: ',', type: 'keypress' })) + expect(getKittyKeyboardFlags).not.toHaveBeenCalled() + + dispatchInsertText(textarea, ',') + expect(getKittyKeyboardFlags).toHaveBeenCalledOnce() + }) + + // A held key emits repeated keydowns. The protocol reports those as REPEAT (event type 2); + // encoding them all as PRESS would make one held key read as N separate strikes to an app + // that counts presses or filters repeats. + // Flags 8|2: the event type only appears on the wire when report_event_types is also + // negotiated, which is exactly the pane that can tell a repeat from a press. + it('encodes an auto-repeat commit as REPEAT, not as another PRESS', () => { + const { forwarder, sendInput } = installWithFlags(() => 0b1010) + + expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA' }))).toBe(true) + dispatchInsertText(textarea, 'a') + const firstPress = sendInput.mock.calls[0][0] + + expect(forwarder.claimKeyEvent(keyEvent({ key: 'a', code: 'KeyA', repeat: true }))).toBe(true) + dispatchInsertText(textarea, 'a') + const repeated = sendInput.mock.calls[1][0] + + expect(firstPress).toBe('') + expect(repeated).toBe('[97;1:2u') + }) + + it('claims the keydown under bit 3 exactly as it does without it', () => { + // The predicate stays structural: the protocol changes what the commit + // writes, never whether the keystroke is owned. + const { forwarder } = installWithFlags(() => 8) + expect(forwarder.claimKeyEvent(keyEvent({ key: ',' }))).toBe(true) + }) + + it('leaves a composing keystroke to the composition path even under bit 3', () => { + // Scope boundary: a composing IME (Hangul, kana) is never claimed here, so + // its commit is not this path's to re-encode. Bit 3 fidelity for + // composition commits would be a change to the composition path. + const { forwarder, sendInput } = installWithFlags( + () => 8, + () => true + ) + expect(forwarder.claimKeyEvent(keyEvent({ key: 'r' }))).toBe(false) + dispatchInsertText(textarea, '한') + expect(sendInput).not.toHaveBeenCalled() + }) + + it('writes the commit raw when the caller tracks no flags at all', () => { + // The preview bridge installs the forwarder with no pane to negotiate with. + const sendInput = vi.fn() + const forwarder = installTerminalImeNativeTextForwarder({ + terminalElement: element, + isComposing: () => false, + sendInput + }) + forwarder.claimKeyEvent(keyEvent({ key: ',' })) + dispatchInsertText(textarea, ',') + expect(sendInput).toHaveBeenCalledExactlyOnceWith(',') + }) + }) + it('stops forwarding after dispose', () => { const { forwarder, sendInput } = install() forwarder.claimKeyEvent(keyEvent({ key: ',' })) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts index 4d268f60a..c200e3c04 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.ts @@ -1,4 +1,5 @@ import type { IDisposable } from '@xterm/xterm' +import { encodeImeCommitAsKittyReport } from './terminal-ime-kitty-commit-encoding' // Why: a plain printable keydown never produces terminal bytes. Bytes for // printable characters come only from the `input` event, which on macOS *is* @@ -13,6 +14,8 @@ import type { IDisposable } from '@xterm/xterm' type ClaimedKeyPress = { key: string code?: string + shiftKey: boolean + repeat?: boolean } export type ImeNativeTextKeyEvent = { @@ -22,6 +25,8 @@ export type ImeNativeTextKeyEvent = { metaKey: boolean ctrlKey: boolean altKey: boolean + shiftKey?: boolean + repeat?: boolean isComposing?: boolean } @@ -82,6 +87,12 @@ export function installTerminalImeNativeTextForwarder(args: { terminalElement: HTMLElement | null | undefined isComposing: () => boolean sendInput: (data: string) => void + /** + * The pane's negotiated kitty flags. Read once per commit, never on the + * keydown — `claimKeyEvent` stays structural and protocol-blind so the hot + * path keeps no kitty state. Absent means no pane to negotiate with. + */ + getKittyKeyboardFlags?: () => number }): TerminalImeNativeTextForwarder { if (!args.terminalElement) { return { @@ -114,7 +125,12 @@ export function installTerminalImeNativeTextForwarder(args: { // never arrived (the input source swallowed the key) — no timer needed. pendingForward = true forwardedPressBytes = false - claimedPress = { key: event.key, code: event.code } + claimedPress = { + key: event.key, + code: event.code, + shiftKey: event.shiftKey === true, + repeat: event.repeat === true + } return true } if (!claimedPress) { @@ -161,7 +177,11 @@ export function installTerminalImeNativeTextForwarder(args: { return } if (event.data) { - args.sendInput(event.data) + const kittyReport = encodeImeCommitAsKittyReport( + claimedPress, + args.getKittyKeyboardFlags?.() ?? 0 + ) + args.sendInput(kittyReport ?? event.data) forwardedPressBytes = true } event.stopImmediatePropagation() diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts index 9dfa43075..267c7a610 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-substituted-text-commit.test.ts @@ -25,7 +25,8 @@ function open(kittyKeyboardFlags = 0) { const forwarder = installTerminalImeNativeTextForwarder({ terminalElement: terminal.element, isComposing: () => false, - sendInput: (data) => terminal.input(data) + sendInput: (data) => terminal.input(data), + getKittyKeyboardFlags: () => kittyKeyboardFlags }) terminal.attachCustomKeyEventHandler((event) => { if (forwarder.claimKeyEvent(event)) { @@ -188,12 +189,59 @@ describe('input-source text substitution reaches the terminal', () => { expect(type([COMMA], 1)).toBe(',') }) - // Pins a deliberate hole rather than a desired behaviour. Flag 8 asks for every printable key as - // an escape code, and this path sends the committed text raw instead — a mature native terminal - // makes the same trade, preferring correct characters to protocol fidelity. Recorded here so the - // choice is visible: if this ever needs closing, gate on flag 8 alone, never on "kitty active", - // which would disable the substitution for every pane that negotiates anything. - it('sends the substitution raw even when kitty asks for all keys as escape codes', () => { - expect(type([COMMA], 8)).toBe(',') + // The gate is bit 3 alone. Every other flag leaves printable keys as text, so the substituted + // character must still reach the pane; gating on "kitty active" instead would strip the + // substitution from every pane that negotiates anything at all. + describe.each([ + ['none', 0], + ['disambiguate', 1], + ['event types', 2], + ['alternate keys', 4], + ['disambiguate + alternate keys', 5], + ['disambiguate + event types + alternate keys', 7], + // Bit 4 asks for associated text, which only decorates a report bit 3 would already have + // produced — on its own it does not turn a printable key into one. + ['associated text', 16] + ])('with kitty flags %s (%d) negotiated', (_name, flags) => { + it('sends the substituted character raw', () => { + expect(type([COMMA], flags)).toBe(',') + }) + }) + + // Bit 3 is `report_all_keys_as_escape_codes`: the app asked for every printable key as a CSI-u + // report, so committing raw UTF-8 hands it a byte stream it declined. Re-encode the press that + // produced the commit. This is not CJK-specific — it is every printable key in such a pane. + describe.each([ + ['all keys as escape codes', 8], + ['all keys + disambiguate', 9], + ['all keys + disambiguate + event types + alternate keys', 15] + ])('with kitty flags %s (%d) negotiated', (_name, flags) => { + it('sends a CSI-u report for the physical key instead of the substituted character', () => { + // `,` is the physical Comma key; U+002C is 44. The committed `,` is deliberately absent — + // see terminal-ime-kitty-commit-encoding.ts on why bit 3 without an encoder change cannot + // carry it. + expect(type([COMMA], flags)).toBe('\x1b[44u') + }) + }) + + it('reports the physical key as the associated text under bit 3 + bit 4, not the substitution', () => { + // Pins the limit named in terminal-ime-kitty-commit-encoding.ts: bit 4 is where the committed + // glyph U+FF0C (65292) would ride, and 44 shows up in that slot instead. Closing that needs the + // encoder to take the text separately from the key, so it is not reachable by widening a gate. + expect(type([COMMA], 24)).toBe('\x1b[44;;44u') + }) + + it('encodes a shifted substitution as a CSI-u report with the shift modifier', () => { + // Shift is the one modifier the claim keeps eligible, so it has to survive the encoding. + // 63 is `?`, not 47 for the unshifted `/`: xterm's encoder only unwinds a shifted key to its + // base through `Digit*`/`Key*` codes, and this press carries `Slash`. Pinned as-is — that is + // the shared encoder's behaviour for shifted punctuation on every path, not something the + // commit path introduces. + expect(type([QUESTION], 8)).toBe('\x1b[63;2u') + }) + + it('encodes a multi-character substitution as one report, not one per character', () => { + // One press produced `——`; bit 3 reports keys, and this was a single key. + expect(type([EM_DASH], 8)).toBe('\x1b[95;2u') }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 759e7ad86..631287b72 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -920,7 +920,9 @@ export function useTerminalPaneLifecycle({ ? installTerminalImeNativeTextForwarder({ terminalElement: pane.terminal.element, isComposing: () => imeCompositionTracker.isActive(), - sendInput: (data) => pane.terminal.input(data) + sendInput: (data) => pane.terminal.input(data), + getKittyKeyboardFlags: () => + paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0 }) : { claimKeyEvent: () => false,