From 83676d2e0158af9a1ce13eecec1200711dbcdb04 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:04:03 +0800 Subject: [PATCH 1/2] fix(input): route macOS text through the IME so synthesized keys keep their text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpui derives `key_char` by running the event's virtual keycode back through the current layout; it never reads the event's Unicode payload. That is fine for a physical keyboard, but remote-control apps synthesize keystrokes as `CGEventCreateKeyboardEvent(src, 0, ...)` + `CGEventKeyboardSetUnicodeString()`, putting the real text only in the payload. Keycode 0 is `a`, so every character typed from a phone arrived in the terminal as `a`. gpui does divert printable keys to the input context, but only while a composing input source is active (`is_ime_input_source_active`), so the bug appeared and vanished depending on the selected input method — and the plain ABC layout, the macOS default, always lost the text. Make the IME the single delivery path for text on macOS: `on_key_down` declines plain printable keys without consuming them, so gpui falls through to `handleEvent:` and the Unicode payload survives into `commit_text`. Chords are excluded — Ctrl/Cmd/Fn belong to the encoders, Option to the Meta policy — and a pending multi-key chord still wins, matching `prefers_ime_for_printable_keys`. Answer `apple_press_and_hold_enabled()` with false as well. `on_key_down` used to consume printable keys before gpui consulted it; now that gpui reaches its held-key branch, false is what keeps auto-repeat instead of handing the key to the accent palette — a terminal wants `jjj`, not `ĵ`. Tests that assert on text input now go through a `type_char` helper that follows the platform: `commit_text` on macOS, `on_key_down` elsewhere. Building a `KeyDownEvent` by hand exercised a path macOS no longer takes. Production semantics are unchanged: `input_active()` is already false at a shell vi-mode prompt and `write_gap_text` has its own `shell_vi_prompt()` branch. Linux is unaffected and still uses the `key_char` path, since gpui's IBus integration does not commit plain ASCII back through `replace_text_in_range`. --- src/terminal/input.rs | 42 +++++++++++++++++++++++++++++ src/terminal/view.rs | 62 +++++++++++++++++++++++++++++-------------- 2 files changed, 84 insertions(+), 20 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index e4fc4bfe..cda6a89d 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -105,6 +105,38 @@ pub(super) fn reshape_option_keystroke( } } +/// True when a keystroke is ordinary text that macOS should deliver through the +/// input context (`insertText:` → `replace_text_in_range` → `commit_text`) +/// rather than the raw `key_char` path. +/// +/// gpui derives `key_char` by running the event's *virtual keycode* back through +/// the current layout (`chars_for_modified_key` in its macOS backend); it never +/// reads the event's Unicode payload. That is fine for a physical keyboard, where +/// the keycode is the truth, but wrong for any event whose text lives only in the +/// payload — notably remote-control apps, which synthesize keystrokes as +/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`. +/// Keycode 0 is `a`, so every remotely typed character arrived as `a`. +/// +/// gpui already diverts printable keys to the input context, but only while a +/// composing input source is active (`is_ime_input_source_active`), so the bug +/// appeared and vanished depending on which input method was selected — and the +/// plain ABC layout, the macOS default, always lost the text. Declining the key +/// here instead makes the IME the single delivery path for text on macOS: gpui +/// falls through to `handleEvent:`, and the Unicode payload survives. +/// +/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below, +/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy. +#[cfg(target_os = "macos")] +pub(super) fn defer_to_ime(ks: &gpui::Keystroke) -> bool { + let m = &ks.modifiers; + if m.control || m.platform || m.function || m.alt { + return false; + } + ks.key_char + .as_deref() + .is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}')) +} + /// Translate a GPUI keystroke into the bytes a PTY expects. /// /// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try @@ -522,6 +554,16 @@ impl InputHandler for TerminalInputHandler { None } + fn apple_press_and_hold_enabled(&mut self) -> bool { + // A terminal wants auto-repeat, not the accent palette: holding `j` in + // vim scrolls, it does not offer `ĵ`. This used to be moot because + // `on_key_down` consumed printable keys before gpui consulted it; now + // that text defers to the IME (see `defer_to_ime`), gpui reaches its + // held-key branch, and answering `false` there makes it repeat the + // character instead of handing the key to press-and-hold. + false + } + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool { // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's // `ctrl-b` prefix is held pending — the next key belongs to the keymap, diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7bf44e45..f37210f7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1367,6 +1367,18 @@ impl TerminalView { return; } + // On macOS all ordinary text goes out through the IME, never through + // `key_char` — see `input::defer_to_ime` for why (gpui reconstructs + // `key_char` from the virtual keycode, which is a lie for synthesized + // events). Decline the key without consuming it and gpui hands the + // native event to the input context, which delivers the real text via + // `commit_text`. A pending multi-key chord is the exception: that key + // belongs to the keymap, matching `prefers_ime_for_printable_keys`. + #[cfg(target_os = "macos")] + if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks) { + return; + } + // While idle at the prompt, our local command editor owns the keyboard: // editing keys act on the in-memory line and Enter ships it to the PTY. // Printable text is delivered through the IME path (`commit_text`), so we @@ -6060,6 +6072,34 @@ mod gpui_tests { } } + /// Deliver one printable character the way the running platform actually + /// does. macOS hands all text to the input context, which arrives as + /// `commit_text` (see `input::defer_to_ime`); elsewhere it travels the + /// `on_key_down` / `key_char` path. Tests that assert on *text* input must + /// go through here, or they exercise a path the platform never takes. + fn type_char( + view: &mut TerminalView, + ch: &str, + window: &mut Window, + cx: &mut Context, + ) { + if cfg!(target_os = "macos") { + let _ = window; + view.commit_text(ch, cx); + } else { + let ev = KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: ch.to_string(), + key_char: Some(ch.to_string()), + }, + is_held: false, + prefer_character_input: false, + }; + view.on_key_down(&ev, window, cx); + } + } + fn next_input_until_timeout(daemon: &mut UnixStream) -> Option> { use std::io::ErrorKind; @@ -6131,16 +6171,7 @@ mod gpui_tests { !view.input_active(), "shell vi-mode lets the shell line editor own prompt input" ); - let a = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "a".to_string(), - key_char: Some("a".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&a, window, cx); + type_char(view, "a", window, cx); assert_eq!( view.cmd.text(), "", @@ -6206,16 +6237,7 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - let i = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "i".to_string(), - key_char: Some("i".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&i, window, cx); + type_char(view, "i", window, cx); }) .unwrap(); assert_eq!( From 6c07356ea695d06c3dfc70a8cb7d264ec1d66874 Mon Sep 17 00:00:00 2001 From: thomas Date: Sun, 19 Jul 2026 18:53:34 +0800 Subject: [PATCH 2/2] fix(input): keep Kitty full mode off the IME path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `defer_to_ime` early return sat above `keystroke_to_bytes`, and the IME path terminates in `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. So under REPORT_ALL_KEYS_AS_ESC a plain `a` reached the PTY as `a` instead of the `CSI 97;1;97u` the app negotiated — on macOS, unconditionally. Before this branch it worked on the default ABC layout, since the key reached the encoder whenever no IME source was active. Fold the check into `defer_to_ime` so both routing sites agree: it now declines under `report_all_keys`, and `prefers_ime_for_printable_keys` gets the same gate (CJK composition and "escape every key" are mutually exclusive by construction — an app that asks for the latter gets it). Disambiguate-only sessions are untouched: `encode_kitty` already declines unmodified text keys there, so the IME route is equivalent. The existing Kitty tests call `keystroke_to_bytes` directly and so could not see this. `defer_to_ime` now compiles under `test` on every platform and has coverage for the routing rule itself, so all three CI targets exercise it rather than the macOS runner alone. Co-Authored-By: Claude Opus 4.8 --- src/terminal/input.rs | 117 ++++++++++++++++++++++++++++++++++++++++-- src/terminal/view.rs | 16 ++++-- 2 files changed, 125 insertions(+), 8 deletions(-) diff --git a/src/terminal/input.rs b/src/terminal/input.rs index cda6a89d..5e0259bc 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -126,8 +126,21 @@ pub(super) fn reshape_option_keystroke( /// /// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below, /// and Option is owned by [`reshape_option_keystroke`]'s Meta policy. -#[cfg(target_os = "macos")] -pub(super) fn defer_to_ime(ks: &gpui::Keystroke) -> bool { +/// +/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as +/// `CSI ;[;]u`, and the IME path terminates in +/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that +/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get +/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`] +/// declines unmodified text keys there, so the IME route is equivalent. +/// +/// Compiled under `test` on every platform so the routing rule is covered by +/// CI everywhere, not just on the macOS runner. +#[cfg(any(target_os = "macos", test))] +pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { + if kitty.report_all_keys { + return false; + } let m = &ks.modifiers; if m.control || m.platform || m.function || m.alt { return false; @@ -564,7 +577,16 @@ impl InputHandler for TerminalInputHandler { false } - fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool { + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool { + // REPORT_ALL_KEYS_AS_ESC wants every key as `CSI ;[;]u`, + // which only `keystroke_to_bytes` produces — the IME path commits raw + // UTF-8. Keep printable keys on the dispatch path so they get encoded, + // matching the same gate in `on_key_down`. CJK composition and "escape + // every key" are mutually exclusive by construction; an app that asks + // for the latter gets it. + if self.view.read(cx).kitty_flags().report_all_keys { + return false; + } // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's // `ctrl-b` prefix is held pending — the next key belongs to the keymap, // not the IME. macOS otherwise diverts printable keys straight to the IME @@ -596,9 +618,29 @@ impl InputHandler for TerminalInputHandler { #[cfg(test)] mod tests { - use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes}; + use super::{ + KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes, + }; use gpui::{Keystroke, Modifiers}; + /// Kitty full mode: every key escaped, with the produced text attached. + fn full_mode() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: true, + report_text: true, + } + } + + /// Level 1 only — the mode a shell leaves on after a TUI exits. + fn disambiguate_only() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: false, + report_text: false, + } + } + /// The legacy call shape used by the pre-existing tests: encode with the Kitty /// protocol off, exercising exactly the byte output shells see by default. fn legacy(ks: &Keystroke) -> Option> { @@ -613,6 +655,73 @@ mod tests { } } + #[test] + fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() { + let plain = Modifiers::default(); + let a = ks(plain, "a", Some("a")); + + // Default and disambiguate-only: text belongs to the IME, which is the + // only path that carries a synthesized event's real Unicode payload. + assert!(defer_to_ime(&a, KittyFlags::default())); + assert!(defer_to_ime(&a, disambiguate_only())); + + // Full mode: the IME commits raw UTF-8, so deferring would drop the + // `CSI 97;1;97u` the app negotiated for. Stay on the encoder path. + assert!(!defer_to_ime(&a, full_mode())); + assert_eq!( + keystroke_to_bytes(&a, full_mode()), + Some(b"\x1b[97;1;97u".to_vec()), + ); + + // Space is text too, and follows the same rule. + let space = ks(plain, "space", Some(" ")); + assert!(defer_to_ime(&space, KittyFlags::default())); + assert!(!defer_to_ime(&space, full_mode())); + } + + #[test] + fn shifted_text_follows_the_same_ime_rule() { + let shift = Modifiers { + shift: true, + ..Default::default() + }; + let upper = ks(shift, "a", Some("A")); + assert!(defer_to_ime(&upper, KittyFlags::default())); + assert!(!defer_to_ime(&upper, full_mode())); + } + + #[test] + fn non_text_keys_never_defer_to_the_ime() { + let plain = Modifiers::default(); + // No `key_char` at all — arrows, F-keys, backspace, escape. + assert!(!defer_to_ime( + &ks(plain, "left", None), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "backspace", None), + KittyFlags::default() + )); + // Control chars are filtered even when a `key_char` is present. + assert!(!defer_to_ime( + &ks(plain, "enter", Some("\n")), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "tab", Some("\t")), + KittyFlags::default() + )); + // Chords belong to the encoders, not the IME. + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert!(!defer_to_ime( + &ks(ctrl, "c", Some("c")), + KittyFlags::default() + )); + } + #[test] fn keystroke_to_bytes_maps_control_letters() { let ctrl = Modifiers { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index f37210f7..91a050b0 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1372,10 +1372,18 @@ impl TerminalView { // `key_char` from the virtual keycode, which is a lie for synthesized // events). Decline the key without consuming it and gpui hands the // native event to the input context, which delivers the real text via - // `commit_text`. A pending multi-key chord is the exception: that key - // belongs to the keymap, matching `prefers_ime_for_printable_keys`. + // `commit_text`. + // + // Kitty's REPORT_ALL_KEYS_AS_ESC is the exception — `defer_to_ime` + // declines under it so the key reaches the encoder below. + // + // A pending multi-key chord is already handled before this point: a key + // that completes a sequence is dispatched as an action and never + // reaches `on_key_down`. The check below is belt-and-braces (gpui takes + // `pending_input` earlier in `dispatch_key_event`, so it never fires + // here) and mirrors `prefers_ime_for_printable_keys`, which *is* live. #[cfg(target_os = "macos")] - if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks) { + if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) { return; } @@ -2070,7 +2078,7 @@ impl TerminalView { /// local `Term`'s mode bits (the reader thread keeps them current by advancing /// the emulator over all child output). Consulted by the key encoder so TUIs /// that opt into the protocol get `CSI u` reports. - fn kitty_flags(&self) -> super::input::KittyFlags { + pub(super) fn kitty_flags(&self) -> super::input::KittyFlags { super::input::KittyFlags::from_mode(self.terminal.term.lock().mode()) }