aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorsf-jin-ku <jin.ku@sendbird.com>2026-06-03 09:13:01 -0700
committerGitHub <noreply@github.com>2026-06-03 19:13:01 +0300
commitf5e66b251fa8ec24989bf25861bcf4e38a561470 (patch)
tree3244ffd1af0054334f8fe65ddbb0ff6f5a809cd3 /src/app
parent03f68b7275f07c98769bf699d2aa14805b921f8d (diff)
feat: switch input source during prefix mode (#434)
On macOS, opt-in under [experimental], temporarily switch the host input source to the current ASCII-capable layout while prefix mode is active so prefix commands register even when a CJK IME is composing, then restore the previous input source when prefix mode exits or is cancelled. No-op when the current source is already ASCII-capable, on non-macOS platforms, or when any input source call fails. Pane text input is unaffected. Also pin next_resize_poll in next_loop_deadline_includes_selection_autoscroll_deadline so the selection autoscroll deadline stays the earliest; the default resize poll could otherwise beat it and flake the assertion. refs #400 Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/app')
-rw-r--r--src/app/config_io.rs13
-rw-r--r--src/app/input/mod.rs3
-rw-r--r--src/app/input/settings.rs82
-rw-r--r--src/app/mod.rs185
-rw-r--r--src/app/runtime.rs6
-rw-r--r--src/app/state.rs38
6 files changed, 316 insertions, 11 deletions
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
index fa2765e9..804a26f6 100644
--- a/src/app/config_io.rs
+++ b/src/app/config_io.rs
@@ -94,6 +94,19 @@ impl App {
}
}
+ pub(super) fn save_switch_ascii_input_source_in_prefix(&mut self, enabled: bool) {
+ if self.update_config_file("prefix ascii input source", |content| {
+ crate::config::upsert_section_bool(
+ content,
+ "experimental",
+ "switch_ascii_input_source_in_prefix",
+ enabled,
+ )
+ }) {
+ self.apply_config_from_disk(false);
+ }
+ }
+
pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) {
let value = match scope {
crate::app::state::AgentPanelScope::CurrentWorkspace => {
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index cfcc3039..1da647e8 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -219,6 +219,9 @@ impl App {
SettingsAction::SavePaneHistory(enabled) => {
self.save_pane_history_persistence(enabled)
}
+ SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => {
+ self.save_switch_ascii_input_source_in_prefix(enabled)
+ }
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
}
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
index 986f59e2..66ec3226 100644
--- a/src/app/input/settings.rs
+++ b/src/app/input/settings.rs
@@ -3,7 +3,7 @@ use ratatui::layout::Rect;
use crate::{
app::{
- state::{AppState, SettingsSection, THEME_NAMES},
+ state::{AppState, ExperimentSetting, SettingsSection, THEME_NAMES},
App, Mode,
},
config::ToastDelivery,
@@ -18,9 +18,24 @@ pub(super) enum SettingsAction {
SaveToastDelivery(ToastDelivery),
SaveAgentBorderLabels(bool),
SavePaneHistory(bool),
+ SaveSwitchAsciiInputSourceInPrefix(bool),
InstallRecommendedIntegrations,
}
+/// Map an Experiments row index to the toggle action that flips it.
+fn experiment_toggle_action(state: &AppState, idx: usize) -> Option<SettingsAction> {
+ match ExperimentSetting::ALL.get(idx).copied()? {
+ ExperimentSetting::PaneHistory => Some(SettingsAction::SavePaneHistory(
+ !ExperimentSetting::PaneHistory.enabled(state),
+ )),
+ ExperimentSetting::SwitchAsciiInputSourceInPrefix => {
+ Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix(
+ !ExperimentSetting::SwitchAsciiInputSourceInPrefix.enabled(state),
+ ))
+ }
+ }
+}
+
impl App {
pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
let previous_section = self.state.settings.section;
@@ -35,6 +50,9 @@ impl App {
SettingsAction::SavePaneHistory(enabled) => {
self.save_pane_history_persistence(enabled)
}
+ SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => {
+ self.save_switch_ascii_input_source_in_prefix(enabled)
+ }
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
}
@@ -228,10 +246,12 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
}
},
SettingsSection::Experiments => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.list.move_next(ExperimentSetting::ALL.len())
+ }
KeyCode::Enter | KeyCode::Char(' ') => {
- return Some(SettingsAction::SavePaneHistory(
- !state.pane_history_persistence_enabled(),
- ));
+ return experiment_toggle_action(state, state.settings.list.selected);
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
state.settings.section = SettingsSection::Integrations;
@@ -377,7 +397,11 @@ impl AppState {
}
SettingsSection::Experiments => {
let list_y = area.y + 3;
- (row == list_y).then_some(0)
+ if row >= list_y && row < list_y + ExperimentSetting::ALL.len() as u16 {
+ Some((row - list_y) as usize)
+ } else {
+ None
+ }
}
SettingsSection::Integrations => None,
}
@@ -419,9 +443,7 @@ impl AppState {
let enabled = idx == 0;
Some(SettingsAction::SaveAgentBorderLabels(enabled))
}
- SettingsSection::Experiments => Some(SettingsAction::SavePaneHistory(
- !self.pane_history_persistence_enabled(),
- )),
+ SettingsSection::Experiments => experiment_toggle_action(self, idx),
SettingsSection::Integrations => None,
};
}
@@ -524,6 +546,30 @@ mod tests {
}
#[test]
+ fn settings_experiments_down_then_toggle_switches_ascii_input_source() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.switch_ascii_input_source_in_prefix = false;
+ open_settings_at(&mut state, SettingsSection::Experiments);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+ assert_eq!(state.settings.list.selected, 1);
+
+ let action = update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(
+ action,
+ Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix(true))
+ );
+ assert_eq!(state.mode, Mode::Settings);
+ }
+
+ #[test]
fn settings_tab_cycle_places_experiments_last() {
let mut state = state_with_workspaces(&["test"]);
open_settings_at(&mut state, SettingsSection::PaneLabels);
@@ -607,6 +653,26 @@ mod tests {
}
#[test]
+ fn settings_mouse_click_toggles_switch_ascii_input_source_row() {
+ let mut app = app_for_mouse_test();
+ app.state.switch_ascii_input_source_in_prefix = false;
+ open_settings_at(&mut app.state, SettingsSection::Experiments);
+
+ let area = app.state.settings_content_rect();
+ let action = app.state.handle_settings_mouse(mouse(
+ MouseEventKind::Down(crossterm::event::MouseButton::Left),
+ area.x + 2,
+ area.y + 4,
+ ));
+
+ assert_eq!(
+ action,
+ Some(SettingsAction::SaveSwitchAsciiInputSourceInPrefix(true))
+ );
+ assert_eq!(app.state.settings.list.selected, 1);
+ }
+
+ #[test]
fn integration_update_badge_only_tracks_outdated_recommendations() {
let mut state = state_with_workspaces(&["test"]);
state.integration_recommendations = vec![integration_recommendation(
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 5a39b8e2..18d52bfc 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -121,6 +121,7 @@ pub struct App {
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
pub(crate) local_terminal_notifications: bool,
pub(crate) config_reloaded_from_disk: bool,
+ prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
}
pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256;
@@ -505,6 +506,9 @@ impl App {
cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(),
cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents),
cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(),
+ switch_ascii_input_source_in_prefix: config
+ .experimental
+ .switch_ascii_input_source_in_prefix,
kitty_graphics_enabled: config.experimental.kitty_graphics,
default_shell: config.terminal.default_shell.clone(),
shell_mode: config.terminal.shell_mode,
@@ -598,6 +602,7 @@ impl App {
overlay_panes: HashMap::new(),
local_terminal_notifications: true,
config_reloaded_from_disk: false,
+ prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
}
}
@@ -675,6 +680,36 @@ impl App {
self.full_redraw_pending = true;
}
+ pub(crate) fn sync_prefix_input_source(&mut self, previous_mode: Mode) {
+ match (
+ previous_mode == Mode::Prefix,
+ self.state.mode == Mode::Prefix,
+ ) {
+ (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(),
+ _ => {}
+ }
+ }
+
+ pub(crate) fn handle_internal_event_with_prefix_sync(
+ &mut self,
+ event: crate::events::AppEvent,
+ ) {
+ let previous_mode = self.state.mode;
+ self.handle_internal_event(event);
+ self.sync_prefix_input_source(previous_mode);
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_prefix_input_source(
+ &mut self,
+ source: Box<dyn crate::platform::PrefixInputSource>,
+ ) {
+ self.prefix_input_source = source;
+ }
+
pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
if self.input_rx.is_none() {
self.input_rx = Some(crate::raw_input::spawn_input_reader());
@@ -851,7 +886,7 @@ impl App {
match event {
LoopEvent::Timer => {}
LoopEvent::Internal(ev) => {
- self.handle_internal_event(ev);
+ self.handle_internal_event_with_prefix_sync(ev);
needs_render = true;
}
LoopEvent::Api(msg) => {
@@ -1150,6 +1185,8 @@ impl App {
self.state.cjk_ime_agents = parse_cjk_ime_agents(&config.experimental.cjk_ime_agents);
self.state.cjk_ime_cursor_shape =
config.experimental.cjk_ime_cursor_shape.to_decscusr();
+ self.state.switch_ascii_input_source_in_prefix =
+ config.experimental.switch_ascii_input_source_in_prefix;
self.persist_pane_history = config.experimental.pane_history;
self.state.pane_history_persistence = config.experimental.pane_history;
if !self.persist_pane_history {
@@ -1242,6 +1279,7 @@ impl App {
apply_host_terminal_theme: bool,
) {
for event in events {
+ let previous_mode = self.state.mode;
match event {
crate::raw_input::RawInputEvent::Key(key) => {
let key_id = repeat_key_identity(&key);
@@ -1313,6 +1351,7 @@ impl App {
}
crate::raw_input::RawInputEvent::Unsupported => {}
}
+ self.sync_prefix_input_source(previous_mode);
}
}
@@ -1402,6 +1441,8 @@ mod tests {
use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
+ use std::cell::Cell;
+ use std::rc::Rc;
use std::sync::Mutex;
fn raw_key(
@@ -1434,6 +1475,144 @@ mod tests {
)
}
+ #[derive(Clone, Default)]
+ struct FakePrefixInputSource {
+ switch_calls: Rc<Cell<usize>>,
+ restore_calls: Rc<Cell<usize>>,
+ switched: Rc<Cell<bool>>,
+ will_switch: bool,
+ }
+
+ impl FakePrefixInputSource {
+ fn switching() -> Self {
+ Self {
+ will_switch: true,
+ ..Self::default()
+ }
+ }
+
+ fn no_op() -> Self {
+ Self {
+ will_switch: false,
+ ..Self::default()
+ }
+ }
+ }
+
+ impl crate::platform::PrefixInputSource for FakePrefixInputSource {
+ fn switch_to_ascii(&mut self) {
+ self.switch_calls.set(self.switch_calls.get() + 1);
+ if self.will_switch {
+ self.switched.set(true);
+ }
+ }
+
+ fn restore(&mut self) {
+ if self.switched.replace(false) {
+ self.restore_calls.set(self.restore_calls.get() + 1);
+ }
+ }
+ }
+
+ #[test]
+ fn sync_prefix_input_source_switches_then_restores_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.
+ app.state.mode = Mode::Prefix;
+ app.sync_prefix_input_source(Mode::Terminal);
+ 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);
+ assert_eq!(restore_calls.get(), 1);
+ }
+
+ #[test]
+ fn sync_prefix_input_source_is_noop_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));
+
+ 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);
+
+ 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.
+ 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.
+ app.handle_raw_input_event(raw_key(
+ KeyCode::Char('b'),
+ KeyModifiers::CONTROL,
+ KeyEventKind::Press,
+ ))
+ .await;
+ assert_eq!(app.state.mode, Mode::Prefix);
+ assert_eq!(switch_calls.get(), 1);
+ assert_eq!(restore_calls.get(), 0);
+
+ // Esc leaves prefix mode → restore edge, even though the exit is decided
+ // below `handle_key`.
+ app.handle_raw_input_event(raw_key(
+ KeyCode::Esc,
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ ))
+ .await;
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert_eq!(restore_calls.get(), 1);
+ }
+
fn config_env_lock() -> &'static Mutex<()> {
crate::config::test_config_env_lock()
}
@@ -1730,7 +1909,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1769,6 +1948,7 @@ mod tests {
app.state.new_terminal_cwd,
crate::config::NewTerminalCwdConfig::Home
);
+ assert!(app.state.switch_ascii_input_source_in_prefix);
assert!(app.state.config_diagnostic.is_none());
let toast = app.state.toast.as_ref().unwrap();
assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled);
@@ -3018,6 +3198,7 @@ mod tests {
fn next_loop_deadline_includes_selection_autoscroll_deadline() {
let mut app = test_app();
let now = Instant::now();
+ app.next_resize_poll = now + Duration::from_millis(300);
app.selection_autoscroll_deadline = Some(now + Duration::from_millis(5));
app.next_animation_tick = Some(now + Duration::from_millis(100));
app.session_save_deadline = Some(now + Duration::from_millis(200));
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 4ddcde6c..5bfa9141 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -61,9 +61,11 @@ impl App {
&mut self,
msg: crate::api::ApiRequestMessage,
) -> bool {
+ let previous_mode = self.state.mode;
let changed = crate::api::request_changes_ui(&msg.request);
let response = self.handle_api_request(msg.request);
let _ = msg.respond_to.send(response);
+ self.sync_prefix_input_source(previous_mode);
changed
}
@@ -91,6 +93,7 @@ impl App {
&mut self,
event: crate::raw_input::RawInputEvent,
) -> bool {
+ let previous_mode = self.state.mode;
let changed = match event {
crate::raw_input::RawInputEvent::Key(key) => {
let key_id = repeat_key_identity(&key);
@@ -150,6 +153,7 @@ impl App {
}
crate::raw_input::RawInputEvent::Unsupported => false,
};
+ self.sync_prefix_input_source(previous_mode);
self.shutdown_detached_terminal_runtimes();
changed
}
@@ -552,7 +556,7 @@ impl App {
break;
};
had_event = true;
- self.handle_internal_event(ev);
+ self.handle_internal_event_with_prefix_sync(ev);
}
had_event
}
diff --git a/src/app/state.rs b/src/app/state.rs
index dd1b167e..a25c7c5e 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -867,6 +867,34 @@ impl SettingsSection {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ExperimentSetting {
+ PaneHistory,
+ SwitchAsciiInputSourceInPrefix,
+}
+
+impl ExperimentSetting {
+ pub(crate) const ALL: [Self; 2] = [Self::PaneHistory, Self::SwitchAsciiInputSourceInPrefix];
+
+ pub(crate) fn label(self) -> &'static str {
+ match self {
+ Self::PaneHistory => "pane screen history",
+ Self::SwitchAsciiInputSourceInPrefix => {
+ "switch to ascii input source in prefix (macOS)"
+ }
+ }
+ }
+
+ pub(crate) fn enabled(self, state: &AppState) -> bool {
+ match self {
+ Self::PaneHistory => state.pane_history_persistence_enabled(),
+ Self::SwitchAsciiInputSourceInPrefix => {
+ state.switch_ascii_input_source_in_prefix_enabled()
+ }
+ }
+ }
+}
+
/// All built-in theme names in display order.
pub const THEME_NAMES: &[&str] = &[
"catppuccin",
@@ -1268,6 +1296,11 @@ pub struct AppState {
pub cjk_ime_agents: Vec<crate::detect::Agent>,
/// DECSCUSR shape parameter (1–6) for the IME anchor cursor.
pub cjk_ime_cursor_shape: u8,
+ /// While prefix mode is active, switch the macOS host input source to an
+ /// ASCII-capable layout so prefix commands register as ASCII even when a
+ /// CJK IME is active. macOS only; a no-op elsewhere. See
+ /// `[experimental] switch_ascii_input_source_in_prefix`.
+ pub switch_ascii_input_source_in_prefix: bool,
pub kitty_graphics_enabled: bool,
pub default_shell: String,
pub shell_mode: crate::config::ShellModeConfig,
@@ -1327,6 +1360,10 @@ impl AppState {
self.pane_history_persistence
}
+ pub fn switch_ascii_input_source_in_prefix_enabled(&self) -> bool {
+ self.switch_ascii_input_source_in_prefix
+ }
+
pub(crate) fn integration_updates_available(&self) -> bool {
self.integration_recommendations
.iter()
@@ -1575,6 +1612,7 @@ impl AppState {
cjk_ime_agent_filter_configured: false,
cjk_ime_agents: Vec::new(),
cjk_ime_cursor_shape: 2, // steady_block
+ switch_ascii_input_source_in_prefix: false,
kitty_graphics_enabled: false,
default_shell: String::new(),
shell_mode: crate::config::ShellModeConfig::Auto,