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>
This commit is contained in:
ppggff 2026-07-05 08:25:47 +08:00 committed by GitHub
parent e713949ca5
commit 2bc1724c2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 404 additions and 74 deletions

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"protocol": 15,
"protocol": 16,
"schema_version": 1,
"schemas": {
"error_response": {

View File

@ -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).

View File

@ -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,

View File

@ -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();

View File

@ -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,

View File

@ -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;
// 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<()> {

View File

@ -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 {

View File

@ -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");
}

View File

@ -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 {

View File

@ -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)]

View File

@ -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();
}
}

View File

@ -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]

View File

@ -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<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();
@ -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(")
{

View File

@ -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);
}

View File

@ -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()));

View File

@ -15,7 +15,7 @@ static INIT: Once = Once::new();
static CLEANUP_GUARD: OnceLock<CleanupGuard> = 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<u32>) {
let Some(pid) = pid else {