aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorppggff <pgf00a@gmail.com>2026-07-05 08:25:47 +0800
committerGitHub <noreply@github.com>2026-07-05 03:25:47 +0300
commit2bc1724c2dae72184a5d2ec070e30c70dc519f9b (patch)
tree573a960e82f832455d303ca97ea1783587482ad6 /src/app
parente713949ca51facf1e40c584b271f812f527bc522 (diff)
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 <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app')
-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
4 files changed, 193 insertions, 49 deletions
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 {