aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/tests.rs2
-rw-r--r--src/app/actions.rs6
-rw-r--r--src/app/api.rs16
-rw-r--r--src/app/mod.rs193
-rw-r--r--src/app/state.rs27
-rw-r--r--src/client/mod.rs11
-rw-r--r--src/events.rs5
-rw-r--r--src/platform/macos.rs28
-rw-r--r--src/platform/mod.rs6
-rw-r--r--src/protocol/wire.rs21
-rw-r--r--src/server/headless.rs120
11 files changed, 382 insertions, 53 deletions
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<crate::layout::PaneId, OverlayPaneState>,
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<dyn crate::platform::PrefixInputSource>,
}
@@ -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<bool> {
+ 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;
- 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.
+ // Terminal -> Prefix emits the ASCII-switch intent.
app.state.mode = Mode::Prefix;
app.sync_prefix_input_source(Mode::Terminal);
- assert_eq!(switch_calls.get(), 1);
- assert_eq!(restore_calls.get(), 0);
+ assert_eq!(drained_prefix_active(&mut app), vec![true]);
- // Prefix -> Terminal should restore the saved source.
+ // Prefix -> Terminal emits the restore intent.
app.state.mode = Mode::Terminal;
app.sync_prefix_input_source(Mode::Prefix);
- assert_eq!(switch_calls.get(), 1);
- assert_eq!(restore_calls.get(), 1);
+ assert_eq!(drained_prefix_active(&mut app), vec![false]);
}
#[test]
- fn sync_prefix_input_source_is_noop_when_flag_disabled() {
+ 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;
- 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));
+ // 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]);
+ }
- assert_eq!(switch_calls.get(), 0);
- assert_eq!(restore_calls.get(), 0);
+ #[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_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.
+ 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;
- 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));
+ // 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));
+
+ app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: true });
+ assert_eq!(switch_calls.get(), 1);
+ assert_eq!(restore_calls.get(), 0);
+
+ app.handle_internal_event(crate::events::AppEvent::PrefixInputSource { active: false });
+ assert_eq!(restore_calls.get(), 1);
+ }
+
+ #[test]
+ 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();
+ 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.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<u8> },
+ /// 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<InputSourceRestore> {
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-{}-{}",
@@ -7745,6 +7758,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<std::cell::Cell<usize>>);
+ 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();
let (background_tx, background_control_rx, _background_rx) = test_client_writer();
@@ -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(")
{