From 2bc1724c2dae72184a5d2ec070e30c70dc519f9b Mon Sep 17 00:00:00 2001 From: ppggff Date: Sun, 5 Jul 2026 08:25:47 +0800 Subject: [PATCH] fix: switch prefix ASCII input source on the foreground client (#1016) * fix: switch prefix ASCII input source on the foreground client The macOS prefix ASCII-switch ran in the headless server, whose TISCopyCurrentKeyboardInputSource read is a stale per-process cache (refreshed only via a main-run-loop notification the server never pumps), so prefix could leave the terminal on the wrong input source. Forward it to the foreground client, like clipboard: sync_prefix_input_source emits AppEvent::PrefixInputSource as an intent; the server forwards it to the foreground client and the monolithic app applies it in-process via RealPrefixInputSource, which pumps its main run loop before the stale TIS read. Key it on Mode::wants_ascii_input so multi-level prefix commands keep ASCII until returning to the terminal, and restore the IME for rename text entry. Adds ServerMessage::PrefixInputSource to the already-unreleased protocol 15. refs #774 * fix: keep prefix input-source switch out of the headless server process An App-internal drain (the exhaustive drain at the top of handle_api_request, reachable from prefix-key runtime mutations) can consume a queued PrefixInputSource intent before the server's forwarding drain sees it, applying the TIS switch in the headless server process and stranding the restore state. Gate the in-process switch on App.local_input_source_switch, disabled by the headless server alongside the other local side effects, so a swallowed intent degrades to a skipped switch. Widen the forwarding-bypass maintenance test to the handle_internal_event_with_prefix_sync wrapper. refs #774 * docs: clarify prefix input-source comments and flag docs refs #774 * fix: bump protocol for prefix input source refs #774 --------- Co-authored-by: Ogulcan Celik --- docs/next/CHANGELOG.md | 1 + docs/next/api/herdr-api.schema.json | 2 +- .../src/content/docs/configuration.mdx | 4 +- src/api/schema/tests.rs | 2 +- src/app/actions.rs | 6 +- src/app/api.rs | 16 ++ src/app/mod.rs | 213 +++++++++++++----- src/app/state.rs | 27 +++ src/client/mod.rs | 11 + src/events.rs | 5 + src/platform/macos.rs | 28 +++ src/platform/mod.rs | 6 + src/protocol/wire.rs | 21 +- src/server/headless.rs | 120 +++++++++- tests/api_ping.rs | 2 +- tests/cli_wrapper.rs | 12 +- tests/support/mod.rs | 2 +- 17 files changed, 404 insertions(+), 74 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 27249ac6..cf684726 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -13,6 +13,7 @@ - Added `ui.hide_tab_bar_when_single_tab` to hide the tab row when a workspace has one tab. (#448) ### Changed +- Bumped the client/server protocol version to 16 for foreground-client prefix input-source switching. - Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility. ### Fixed diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index c061e482..4b33e750 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "protocol": 15, + "protocol": 16, "schema_version": 1, "schemas": { "error_response": { diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 0305a670..b17107df 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -500,14 +500,14 @@ The trade-off when enabled: an extra hardware cursor is visible in the outer ter On macOS, prefix-mode commands can be hard to use while a non-ASCII input source is active because prefix commands are still interpreted through the host input source. -Set `switch_ascii_input_source_in_prefix = true` to switch the host input source to the system ASCII-capable input source while prefix mode is active: +Set `switch_ascii_input_source_in_prefix = true` to switch the host input source to the system ASCII-capable input source while prefix commands and prefix-launched navigation are active: ```toml [experimental] switch_ascii_input_source_in_prefix = false ``` -When enabled, Herdr switches input sources only after prefix mode is entered, then restores the previous input source when prefix mode exits. The setting is macOS-only and is a no-op on other platforms or when the system input-source switch fails. +When enabled, Herdr switches input sources after prefix mode is entered, keeps the ASCII source across prefix-launched modes such as navigation, menus, resize, and copy mode, and restores the previous input source when returning to the terminal or entering a text field such as a rename dialog. The setting is macOS-only and is a no-op on other platforms or when the system input-source switch fails. You can also toggle it from Settings > Experiments > switch to ascii input source in prefix (macOS). diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index abd96656..8bb876d0 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -521,7 +521,7 @@ fn session_snapshot_request_and_response_round_trip() { result: ResponseResult::SessionSnapshot { snapshot: Box::new(SessionSnapshot { version: "0.1.2".into(), - protocol: 15, + protocol: 16, focused_workspace_id: None, focused_tab_id: None, focused_pane_id: None, diff --git a/src/app/actions.rs b/src/app/actions.rs index e5742534..019b6692 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2540,9 +2540,11 @@ impl AppState { .collect() } } - // Intercepted in App::handle_internal_event before reaching this - // dispatch; never touches AppState. + // Both intercepted before this dispatch — in App::handle_internal_event (monolithic) + // or via HeadlessServer forwarding to the foreground client (server); never touch + // AppState. Kept for AppEvent exhaustiveness. AppEvent::ClipboardWrite { .. } => Vec::new(), + AppEvent::PrefixInputSource { .. } => Vec::new(), AppEvent::TerminalCwdReported { pane_id, cwd } => { if !cwd.is_absolute() || !cwd.is_dir() { return Vec::new(); diff --git a/src/app/api.rs b/src/app/api.rs index 96c34393..9ebbbe56 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -67,6 +67,22 @@ impl App { return; } + if let AppEvent::PrefixInputSource { active } = ev { + // Monolithic path applies the switch here. Server mode forwards it to the foreground + // client instead (see HeadlessServer::handle_internal_event_with_forwarding); should an + // App-internal drain consume the event before the forwarding drain, the flag keeps the + // switch out of the headless server process. + if !self.local_input_source_switch { + return; + } + if active { + self.prefix_input_source.switch_to_ascii(); + } else { + self.prefix_input_source.restore(); + } + return; + } + if let AppEvent::GitStatusRefreshed { results, cache_updates, diff --git a/src/app/mod.rs b/src/app/mod.rs index 2db6c423..8f33e23a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -137,6 +137,10 @@ pub struct App { pub(crate) full_redraw_pending: bool, pub(crate) overlay_panes: HashMap, pub(crate) local_terminal_notifications: bool, + /// Whether this process applies `AppEvent::PrefixInputSource` to the host input source. + /// The headless server sets this to false: the switch belongs to the foreground client, + /// even when an App-internal drain consumes the event before the forwarding drain. + pub(crate) local_input_source_switch: bool, pub(crate) config_reloaded_from_disk: bool, prefix_input_source: Box, } @@ -731,6 +735,7 @@ impl App { full_redraw_pending: false, overlay_panes: HashMap::new(), local_terminal_notifications: true, + local_input_source_switch: true, config_reloaded_from_disk: false, prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()), } @@ -821,15 +826,23 @@ impl App { } pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) { - match ( - previous_mode == Mode::Prefix, - self.state.mode == Mode::Prefix, + // Emit the input-source intent on entering/leaving the ASCII realm, like `ClipboardWrite`; + // the foreground (client, or this app in monolithic mode) applies the switch. Keyed on the + // realm so multi-level prefix commands stay ASCII. The switch is flag-gated but the restore + // always fires on exit, so a mid-interaction flag toggle can't strand the host on ASCII. + let active = match ( + previous_mode.wants_ascii_input(), + self.state.mode.wants_ascii_input(), ) { - (false, true) if self.state.switch_ascii_input_source_in_prefix => { - self.prefix_input_source.switch_to_ascii(); - } - (true, false) => self.prefix_input_source.restore(), - _ => {} + (false, true) if self.state.switch_ascii_input_source_in_prefix => true, + (true, false) => false, + _ => return, + }; + if let Err(err) = self + .event_tx + .try_send(crate::events::AppEvent::PrefixInputSource { active }) + { + tracing::warn!(active, %err, "failed to queue prefix input-source change"); } } @@ -1775,83 +1788,171 @@ mod tests { } } + /// Drain the app event channel, returning the `active` flags of any emitted + /// `PrefixInputSource` events (the host-local input-source intents). + fn drained_prefix_active(app: &mut App) -> Vec { + let mut out = Vec::new(); + while let Ok(ev) = app.event_rx.try_recv() { + if let crate::events::AppEvent::PrefixInputSource { active } = ev { + out.push(active); + } + } + out + } + #[test] - fn sync_prefix_input_source_switches_then_restores_when_enabled() { + fn sync_prefix_input_source_emits_switch_then_restore_when_enabled() { let mut app = test_app(); app.state.switch_ascii_input_source_in_prefix = true; + + // Terminal -> Prefix emits the ASCII-switch intent. + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + assert_eq!(drained_prefix_active(&mut app), vec![true]); + + // Prefix -> Terminal emits the restore intent. + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Prefix); + assert_eq!(drained_prefix_active(&mut app), vec![false]); + } + + #[test] + fn sync_prefix_input_source_does_not_emit_switch_when_flag_disabled() { + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = false; + + // Entering the realm with the flag off emits nothing. + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + assert!(drained_prefix_active(&mut app).is_empty()); + + // Leaving the realm still emits the restore (harmless if nothing was switched), so a + // mid-interaction flag toggle can't strand the host on ASCII. + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Prefix); + assert_eq!(drained_prefix_active(&mut app), vec![false]); + } + + #[test] + fn mode_wants_ascii_input_classification() { + // Allowlist: the prefix command/navigation realm wants ASCII. + for mode in [ + Mode::Prefix, + Mode::Navigate, + Mode::Navigator, + Mode::Copy, + Mode::Resize, + Mode::ConfirmClose, + Mode::ConfirmRemoveWorktree, + Mode::ContextMenu, + Mode::GlobalMenu, + Mode::KeybindHelp, + ] { + assert!(mode.wants_ascii_input(), "{mode:?} should want ASCII"); + } + // Everything else (terminal, text entry, startup overlays) keeps the user's IME. + for mode in [ + Mode::Terminal, + Mode::RenameWorkspace, + Mode::RenameTab, + Mode::RenamePane, + Mode::NewLinkedWorktree, + Mode::OpenExistingWorktree, + Mode::Settings, + Mode::Onboarding, + Mode::ReleaseNotes, + Mode::ProductAnnouncement, + ] { + assert!(!mode.wants_ascii_input(), "{mode:?} should keep the IME"); + } + } + + #[test] + fn sync_prefix_input_source_keeps_realm_across_multi_level_prefix_commands() { + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = true; + + // Terminal -> Prefix switches once. + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + assert_eq!(drained_prefix_active(&mut app), vec![true]); + + // Prefix -> sub-mode and sub-mode -> sub-mode stay in the realm: no emit. + app.state.mode = Mode::Navigator; + app.sync_prefix_input_source(Mode::Prefix); + app.state.mode = Mode::Resize; + app.sync_prefix_input_source(Mode::Navigator); + assert!( + drained_prefix_active(&mut app).is_empty(), + "must not switch or restore while still in the realm" + ); + + // Leaving the realm back to the terminal restores. + app.state.mode = Mode::Terminal; + app.sync_prefix_input_source(Mode::Resize); + assert_eq!(drained_prefix_active(&mut app), vec![false]); + } + + #[test] + fn sync_prefix_input_source_restores_when_entering_rename_text_mode() { + let mut app = test_app(); + app.state.switch_ascii_input_source_in_prefix = true; + + app.state.mode = Mode::Prefix; + app.sync_prefix_input_source(Mode::Terminal); + assert_eq!(drained_prefix_active(&mut app), vec![true]); + + // Prefix -> RenameTab leaves the realm (text entry wants the IME): restore. + app.state.mode = Mode::RenameTab; + app.sync_prefix_input_source(Mode::Prefix); + assert_eq!(drained_prefix_active(&mut app), vec![false]); + } + + #[test] + fn handle_internal_event_prefix_input_source_applies_switch_and_restore() { + // The monolithic (in-process) path applies the host switch when it consumes the event. + let mut app = test_app(); let fake = FakePrefixInputSource::switching(); let switch_calls = fake.switch_calls.clone(); let restore_calls = fake.restore_calls.clone(); app.set_prefix_input_source(Box::new(fake)); - // Terminal -> Prefix should switch to ASCII. - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); + app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); assert_eq!(switch_calls.get(), 1); assert_eq!(restore_calls.get(), 0); - // Prefix -> Terminal should restore the saved source. - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - assert_eq!(switch_calls.get(), 1); + app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); assert_eq!(restore_calls.get(), 1); } #[test] - fn sync_prefix_input_source_is_noop_when_flag_disabled() { + fn handle_internal_event_prefix_input_source_restore_is_safe_when_switch_was_noop() { + // Already-ASCII / failed-switch case: the restore on leave must stay harmless. let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = false; - let fake = FakePrefixInputSource::switching(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - - assert_eq!(switch_calls.get(), 0); - assert_eq!(restore_calls.get(), 0); - } - - #[test] - fn sync_prefix_input_source_restore_is_safe_when_switch_was_noop() { - // Simulates the already-ASCII / failed-switch case: switch reports no - // change, and the later restore on leave must stay harmless. - let mut app = test_app(); - app.state.switch_ascii_input_source_in_prefix = true; let fake = FakePrefixInputSource::no_op(); let switch_calls = fake.switch_calls.clone(); let restore_calls = fake.restore_calls.clone(); app.set_prefix_input_source(Box::new(fake)); - app.state.mode = Mode::Prefix; - app.sync_prefix_input_source(Mode::Terminal); - app.state.mode = Mode::Terminal; - app.sync_prefix_input_source(Mode::Prefix); - + app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true }); + app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false }); assert_eq!(switch_calls.get(), 1); assert_eq!(restore_calls.get(), 0); } #[tokio::test] - async fn raw_input_dispatch_restores_input_source_when_leaving_prefix() { - // Leaving prefix mode happens inside the raw-input dispatch, not in - // `handle_key` itself — the sync must sit at the dispatch layer so any - // event that exits prefix (here Esc) still restores the host source. + async fn raw_input_dispatch_emits_input_source_intent_when_leaving_prefix() { + // Leaving prefix mode happens inside the raw-input dispatch, not in `handle_key` itself — + // the sync must sit at the dispatch layer so any event that exits prefix (here Esc) still + // emits the restore intent. let mut app = test_app(); app.state.switch_ascii_input_source_in_prefix = true; app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; - let fake = FakePrefixInputSource::switching(); - let switch_calls = fake.switch_calls.clone(); - let restore_calls = fake.restore_calls.clone(); - app.set_prefix_input_source(Box::new(fake)); - // ctrl+b (the default prefix key) enters prefix mode → switch edge. + // ctrl+b (the default prefix key) enters prefix mode → switch intent. app.handle_raw_input_event(raw_key( KeyCode::Char('b'), KeyModifiers::CONTROL, @@ -1859,11 +1960,9 @@ mod tests { )) .await; assert_eq!(app.state.mode, Mode::Prefix); - assert_eq!(switch_calls.get(), 1); - assert_eq!(restore_calls.get(), 0); + assert_eq!(drained_prefix_active(&mut app), vec![true]); - // Esc leaves prefix mode → restore edge, even though the exit is decided - // below `handle_key`. + // Esc leaves prefix mode → restore intent. app.handle_raw_input_event(raw_key( KeyCode::Esc, KeyModifiers::empty(), @@ -1871,7 +1970,7 @@ mod tests { )) .await; assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(restore_calls.get(), 1); + assert_eq!(drained_prefix_active(&mut app), vec![false]); } fn config_env_lock() -> &'static Mutex<()> { diff --git a/src/app/state.rs b/src/app/state.rs index 99ab648b..eee61566 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -768,6 +768,33 @@ pub enum Mode { Navigator, } +impl Mode { + /// Whether keys in this mode are commands/navigation (an ASCII input source is wanted) rather + /// than free text. This is an explicit **allowlist** of the prefix command/navigation realm: + /// any mode NOT listed defaults to leaving the user's IME alone (the safe default), so adding a + /// new text-entry or overlay mode can never silently force ASCII. Used by + /// `sync_prefix_input_source` (gated by `switch_ascii_input_source_in_prefix`) so multi-level + /// prefix commands keep ASCII until they return to the terminal. + /// + /// Known limitation: `Navigator`'s search box is also held on ASCII, since this `Mode`-level + /// predicate can't see `search_focused` (non-ASCII filtering there would need a runtime check). + pub(crate) fn wants_ascii_input(self) -> bool { + matches!( + self, + Mode::Prefix + | Mode::Navigate + | Mode::Navigator + | Mode::Copy + | Mode::Resize + | Mode::ConfirmClose + | Mode::ConfirmRemoveWorktree + | Mode::ContextMenu + | Mode::GlobalMenu + | Mode::KeybindHelp + ) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum NavigatorTarget { Workspace { diff --git a/src/client/mod.rs b/src/client/mod.rs index 6a70c79c..da0e4e53 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1354,6 +1354,10 @@ async fn run_client_loop( .set_nonblocking(false) .map_err(ClientError::ConnectionFailed)?; + // This (foreground) client owns the prefix ASCII input-source switch; a no-op on non-macOS. + use crate::platform::PrefixInputSource; + let mut prefix_input_source = crate::platform::RealPrefixInputSource::default(); + // Main event loop. while !should_quit.load(Ordering::Acquire) { let event = tokio::select! { @@ -1560,6 +1564,13 @@ async fn run_client_loop( host_mouse_capture_active.store(desired, Ordering::Release); } } + ServerMessage::PrefixInputSource { active } => { + if active { + prefix_input_source.switch_to_ascii(); + } else { + prefix_input_source.restore(); + } + } ServerMessage::Welcome { .. } => { debug!("received unexpected Welcome in main loop"); } diff --git a/src/events.rs b/src/events.rs index d9405989..64aa86f0 100644 --- a/src/events.rs +++ b/src/events.rs @@ -130,6 +130,11 @@ pub enum AppEvent { /// A pane child emitted a valid OSC 52 clipboard write. The main loop /// re-emits it through herdr's own clipboard writer. ClipboardWrite { content: Vec }, + /// Prefix-mode ASCII input-source request, emitted on entering/leaving the ASCII input + /// realm. The foreground process applies the host-local TIS switch (`active = true`) / + /// restore (`active = false`): the client in server mode (via server forwarding), the + /// app itself in monolithic mode. + PrefixInputSource { active: bool }, /// A pane child reported its shell current directory through terminal /// metadata such as OSC 7. TerminalCwdReported { diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 3770a3be..e72d32fe 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -97,6 +97,34 @@ extern "C" { buffer_size: CfIndex, encoding: u32, ) -> Boolean; + + #[link_name = "kCFRunLoopDefaultMode"] + static CF_RUN_LOOP_DEFAULT_MODE: CfStringRef; + + #[link_name = "CFRunLoopRunInMode"] + fn cf_run_loop_run_in_mode( + mode: CfStringRef, + seconds: f64, + return_after_source_handled: Boolean, + ) -> libc::c_int; +} + +/// Pump the main thread's run loop once (non-blocking) so the process receives the +/// `kTISNotifySelectedKeyboardInputSourceChanged` notification and refreshes the per-process cache +/// that `TISCopyCurrentKeyboardInputSource` reads. That notification arrives only via the main +/// thread's run loop, so a process that never runs a CFRunLoop (the headless server) reads a stale +/// source. Must run on the main thread. +pub(crate) fn pump_input_source_runloop() { + debug_assert!( + // SAFETY: `pthread_main_np` is always safe to call. + unsafe { libc::pthread_main_np() } != 0, + "pump_input_source_runloop must run on the main thread" + ); + // SAFETY: `CFRunLoopRunInMode` is thread-safe; a 0-second call drains the ready sources and + // returns immediately (no blocking). `CF_RUN_LOOP_DEFAULT_MODE` is a framework-owned constant. + unsafe { + let _ = cf_run_loop_run_in_mode(CF_RUN_LOOP_DEFAULT_MODE, 0.0, 0); + } } #[derive(Debug)] diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 169b7c1c..889c4ce9 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -154,6 +154,9 @@ pub(crate) fn switch_to_ascii_input_source() -> Option { None } +#[cfg(not(target_os = "macos"))] +pub(crate) fn pump_input_source_runloop() {} + /// Switches the host keyboard input source while prefix mode is active. /// /// `App` drives this through a trait so the prefix-mode transitions can be @@ -179,6 +182,9 @@ pub(crate) struct RealPrefixInputSource { impl PrefixInputSource for RealPrefixInputSource { fn switch_to_ascii(&mut self) { if self.restore.is_none() { + // Drain pending input-source-change notifications so the read below is fresh (see + // `pump_input_source_runloop`); a no-op on non-macOS. + pump_input_source_runloop(); self.restore = switch_to_ascii_input_source(); } } diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 8225cb8c..e31f8ff5 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 15; +pub const PROTOCOL_VERSION: u32 = 16; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -656,6 +656,14 @@ pub enum ServerMessage { /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting. enabled: bool, }, + + /// Apply the prefix-mode ASCII input-source change on the foreground client. + /// `active = true` → switch to an ASCII-capable source (saving the current one); + /// `active = false` → restore the saved source. + PrefixInputSource { + /// Whether the ASCII input source should be active. + active: bool, + }, } // --------------------------------------------------------------------------- @@ -1404,6 +1412,17 @@ mod tests { assert_eq!(msg, decoded); } + #[test] + fn server_prefix_input_source_roundtrip() { + for active in [true, false] { + let msg = ServerMessage::PrefixInputSource { active }; + let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); + let (decoded, _): (ServerMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + assert_eq!(msg, decoded); + } + } + // ---- Framing ---- #[test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 761fcfc9..e84dfc59 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1842,6 +1842,14 @@ impl HeadlessServer { } true } + AppEvent::PrefixInputSource { active } => { + // Input-source switching is a client-local host side effect; forward it to the + // foreground client (which owns the real TIS switch + run-loop pump), like clipboard. + self.send_to_foreground_client(ServerMessage::PrefixInputSource { + active: *active, + }); + true + } AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. let toast_before = self.app.state.toast.clone(); @@ -3953,8 +3961,11 @@ pub fn run_server() -> io::Result<()> { // The server runs headless — disable local notification side effects. // Sound and terminal notifications are forwarded to connected clients // as ServerMessage::Notify instead of emitted by the server process. + // The prefix input-source switch is likewise forwarded to the foreground + // client (ServerMessage::PrefixInputSource), never applied in-process. app.state.local_sound_playback = false; app.local_terminal_notifications = false; + app.local_input_source_switch = false; // Create the headless server. let mut server = match HeadlessServer::new( @@ -4054,6 +4065,7 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> )?; app.state.local_sound_playback = false; app.local_terminal_notifications = false; + app.local_input_source_switch = false; crate::server::handoff::report_restored(&mut received.stream)?; if std::env::var("HERDR_TEST_HANDOFF_IMPORT_FAIL").as_deref() == Ok("after_restored") { return Err(io::Error::other( @@ -4151,6 +4163,7 @@ mod tests { let mut app = crate::app::App::new(&config, true, None, api_rx, event_hub); app.state.local_sound_playback = false; app.local_terminal_notifications = false; + app.local_input_source_switch = false; let dir = std::env::temp_dir().join(format!( "hh-{}-{}", @@ -7744,6 +7757,107 @@ next_tab = "" ); } + #[test] + fn prefix_input_source_targets_foreground_client_only() { + let mut server = test_headless_server(); + let (background_tx, background_control_rx, _background_rx) = test_client_writer(); + let (foreground_tx, foreground_control_rx, _foreground_rx) = test_client_writer(); + + server.clients.insert( + 1, + ClientConnection::new( + (120, 40), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(background_tx), + ), + ); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + Some(foreground_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_foreground_client_state(); + // Drain any setup messages (e.g. mouse-capture sync) before exercising the event. + while foreground_control_rx + .recv_timeout(Duration::from_millis(20)) + .is_ok() + {} + + let changed = server + .handle_internal_event_with_forwarding(AppEvent::PrefixInputSource { active: true }); + + assert!(changed); + match read_server_message( + foreground_control_rx + .recv_timeout(Duration::from_millis(100)) + .expect("foreground prefix input-source message"), + ) { + ServerMessage::PrefixInputSource { active } => assert!(active), + other => panic!("expected prefix input-source message, got {other:?}"), + } + assert!( + background_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "background client should not receive prefix input-source changes" + ); + } + + #[test] + fn headless_app_keeps_prefix_input_source_switch_off_process() { + // An App-internal drain (e.g. the exhaustive drain at the top of + // handle_api_request) can consume a queued PrefixInputSource intent + // before the forwarding drain sees it. The headless App must treat the + // event as inert instead of switching the host input source from the + // server process. + struct CountingPrefixInputSource(std::rc::Rc>); + impl crate::platform::PrefixInputSource for CountingPrefixInputSource { + fn switch_to_ascii(&mut self) { + self.0.set(self.0.get() + 1); + } + fn restore(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + let mut server = test_headless_server(); + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + server + .app + .set_prefix_input_source(Box::new(CountingPrefixInputSource(calls.clone()))); + + server + .app + .handle_internal_event(AppEvent::PrefixInputSource { active: true }); + server + .app + .handle_internal_event(AppEvent::PrefixInputSource { active: false }); + assert_eq!( + calls.get(), + 0, + "headless server must not apply the host input-source switch" + ); + + // Sanity: the same event does apply once the flag is on (monolithic semantics). + server.app.local_input_source_switch = true; + server + .app + .handle_internal_event(AppEvent::PrefixInputSource { active: true }); + assert_eq!(calls.get(), 1); + } + #[test] fn client_local_notifications_target_foreground_client_only() { let mut server = test_headless_server(); @@ -8470,7 +8584,8 @@ next_tab = "" } /// Verify that no direct calls to `self.app.handle_internal_event` - /// exist outside of `handle_internal_event_with_forwarding` in this + /// (or its `handle_internal_event_with_prefix_sync` wrapper) exist + /// outside of `handle_internal_event_with_forwarding` in this /// module. This ensures the forwarding bypass cannot be reintroduced. /// /// The search pattern looks for `handle_internal_event` calls that @@ -8508,7 +8623,8 @@ next_tab = "" _ => {} } } - } else if line.contains("self.app.handle_internal_event(") + } else if (line.contains("self.app.handle_internal_event(") + || line.contains("self.app.handle_internal_event_with_prefix_sync(")) && !line.trim().starts_with("///") && !line.contains("contains(") { diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 1d625637..24204548 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() { assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION")); // Intentionally hardcoded so wire protocol bumps require updating this test. // Changing this value means old clients/servers are no longer compatible. - assert_eq!(value["result"]["protocol"], 15); + assert_eq!(value["result"]["protocol"], 16); cleanup_spawned_herdr(child, base); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index d3f67780..eac0b376 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -1620,7 +1620,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {full_stdout}" ); assert!( - full_stdout.contains(" protocol: 15"), + full_stdout.contains(" protocol: 16"), "stdout: {full_stdout}" ); assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}"); @@ -1653,7 +1653,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {server_stdout}" ); assert!( - server_stdout.contains("protocol: 15"), + server_stdout.contains("protocol: 16"), "stdout: {server_stdout}" ); @@ -1665,7 +1665,7 @@ fn status_commands_report_client_and_server_versions() { "stdout: {client_stdout}" ); assert!( - client_stdout.contains("protocol: 15"), + client_stdout.contains("protocol: 16"), "stdout: {client_stdout}" ); assert!( @@ -1675,7 +1675,7 @@ fn status_commands_report_client_and_server_versions() { let full_json = run_cli_json(&socket_path, &["status", "--json"]); assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(full_json["client"]["protocol"], 15); + assert_eq!(full_json["client"]["protocol"], 16); assert_eq!(full_json["server"]["status"], "running"); assert_eq!(full_json["server"]["running"], true); assert_eq!(full_json["server"]["compatible"], true); @@ -1689,12 +1689,12 @@ fn status_commands_report_client_and_server_versions() { let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]); assert_eq!(server_json["status"], "running"); assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(server_json["protocol"], 15); + assert_eq!(server_json["protocol"], 16); assert_eq!(server_json["compatible"], true); let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]); assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(client_json["protocol"], 15); + assert_eq!(client_json["protocol"], 16); assert!(client_json["binary"] .as_str() .is_some_and(|path| !path.is_empty())); diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 5844bfa7..e4a9139a 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -15,7 +15,7 @@ static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; -pub const CURRENT_PROTOCOL: u32 = 15; +pub const CURRENT_PROTOCOL: u32 = 16; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else {