From d3f7eaa7556b69d0399ea80329cafcc72790cd81 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 18 Aug 2026 19:00:46 +0300 Subject: [PATCH] fix: preserve macOS IME commits in report-all mode refs #2924 --- docs/next/CHANGELOG.md | 1 + src/app/mod.rs | 23 +++++++++++++++++++ src/input/parse.rs | 52 ++++++++++++++++++++++++++++++------------ src/terminal_modes.rs | 7 +++++- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index c4bab869ff..c473c09e0b 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -23,6 +23,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- Chinese IME commits now reach panes on macOS when the focused application requests printable key-release events. (#2924) - Foreground typing no longer waits behind render cadence consumed by output from panes in hidden tabs. (#2890) - The Windows ARM64 installer now waits for x64 emulation to release the verified executable before activating the downloaded release. (#2916) - On Unix, Ctrl-click URL openers are now reaped after they exit, preventing defunct child processes from accumulating on long-running servers. (#2903) diff --git a/src/app/mod.rs b/src/app/mod.rs index 501f39f607..75568990d4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5536,6 +5536,29 @@ last_pane = "prefix+tab" assert!(app.input_leases.is_empty()); } + #[tokio::test] + async fn kitty_associated_ime_text_bypasses_report_all_key_encoding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"\x1b[32;;20320:22909u".to_vec()); + + assert_eq!( + rx.recv().await.unwrap(), + bytes::Bytes::from_static("你好".as_bytes()) + ); + assert!(rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + #[tokio::test] async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() { let mut app = test_app(); diff --git a/src/input/parse.rs b/src/input/parse.rs index b2318339f8..ce87fd2f04 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -15,8 +15,14 @@ fn parse_kitty_key_sequence(data: &str) -> Option { let mut fields = body.split(';'); let key_part = fields.next()?; - let modifier_part = fields.next().unwrap_or("1"); - let associated_text = fields.next(); + let modifier_part = fields + .next() + .filter(|field| !field.is_empty()) + .unwrap_or("1"); + let associated_text = match fields.next() { + Some(value) => Some(parse_kitty_associated_text(value)?), + None => None, + }; if fields.next().is_some() { return None; } @@ -31,12 +37,6 @@ fn parse_kitty_key_sequence(data: &str) -> Option { .filter(|field| !field.is_empty()) .and_then(|field| field.parse::().ok()); - if let Some(text) = associated_text { - if text.parse::().ok()? != codepoint { - return None; - } - } - let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; let mut modifiers = key_modifiers_from_u8(modifier); @@ -53,7 +53,19 @@ fn parse_kitty_key_sequence(data: &str) -> Option { if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(shifted_codepoint); } - Some(key) + Some(key.with_generated_text(associated_text)) +} + +fn parse_kitty_associated_text(value: &str) -> Option { + let mut text = String::new(); + for codepoint in value.split(':') { + let ch = char::from_u32(codepoint.parse::().ok()?)?; + if ch.is_control() { + return None; + } + text.push(ch); + } + (!text.is_empty()).then_some(text) } #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. @@ -671,15 +683,25 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); + assert_eq!(key.generated_text.as_deref(), Some("😀")); } #[test] - fn reject_unmodeled_kitty_associated_text() { - assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None); - assert_eq!( - parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"), - None - ); + fn parse_kitty_sequence_with_multicodepoint_ime_text() { + let key = parse_terminal_key_sequence("\x1b[32;;20320:22909u").unwrap(); + + assert_eq!(key.code, KeyCode::Char(' ')); + assert_eq!(key.modifiers, KeyModifiers::empty()); + assert_eq!(key.kind, crossterm::event::KeyEventKind::Press); + assert_eq!(key.generated_text.as_deref(), Some("你好")); + } + + #[test] + fn reject_malformed_kitty_associated_text() { + assert_eq!(parse_terminal_key_sequence("\x1b[32;;1114112u"), None); + assert_eq!(parse_terminal_key_sequence("\x1b[32;;20320:bad:u"), None); + assert_eq!(parse_terminal_key_sequence("\x1b[32;;27u"), None); + assert_eq!(parse_terminal_key_sequence("\x1b[32;;133u"), None); } #[test] diff --git a/src/terminal_modes.rs b/src/terminal_modes.rs index 90ec512bcf..5b6a5d3bbd 100644 --- a/src/terminal_modes.rs +++ b/src/terminal_modes.rs @@ -25,6 +25,11 @@ pub(crate) fn set_host_kitty_keyboard_report_all( let mut flags = crate::input::ime_compatible_keyboard_enhancement_flags(); if report_all_keys { flags |= crossterm::event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; + // Report-all turns IME commits into CSI-u key events in terminals such + // as Ghostty. Ask the terminal to carry the committed text with them. + flags = crossterm::event::KeyboardEnhancementFlags::from_bits_retain( + flags.bits() | 0b0001_0000, + ); } // Older iTerm2 releases clear the keyboard stack on SET, so a later pop // cannot restore the host state. Replace only Herdr's top entry instead. @@ -54,7 +59,7 @@ mod tests { set_host_kitty_keyboard_report_all(&mut output, true).unwrap(); set_host_kitty_keyboard_report_all(&mut output, false).unwrap(); - assert_eq!(output, b"\x1b[<1u\x1b[>15u\x1b[<1u\x1b[>7u"); + assert_eq!(output, b"\x1b[<1u\x1b[>31u\x1b[<1u\x1b[>7u"); } #[test]