diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 23 | ||||
| -rw-r--r-- | src/app/state.rs | 11 | ||||
| -rw-r--r-- | src/config/model.rs | 96 | ||||
| -rw-r--r-- | src/main.rs | 13 | ||||
| -rw-r--r-- | src/server/headless.rs | 143 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 57 |
6 files changed, 336 insertions, 7 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index b87eec66..5cc40ecc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -153,6 +153,21 @@ fn agent_panel_scope_from_config( } } +/// Parse the configured agent name list into a deduplicated set of `Agent` +/// values. Unknown agent names are silently dropped so a typo cannot disable +/// other valid entries. +fn parse_cjk_ime_agents(names: &[String]) -> Vec<crate::detect::Agent> { + let mut out = Vec::with_capacity(names.len()); + for name in names { + if let Some(agent) = crate::detect::parse_agent_label(name) { + if !out.contains(&agent) { + out.push(agent); + } + } + } + out +} + /// Resolve the palette from config: base theme + optional custom overrides. fn resolve_palette(config: &crate::config::Config) -> state::Palette { resolve_palette_with_legacy_accent(config, true) @@ -412,6 +427,9 @@ impl App { confirm_close: config.ui.confirm_close, prompt_new_tab_name: config.ui.prompt_new_tab_name, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, + reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, + cjk_ime_agents: parse_cjk_ime_agents(&config.experimental.cjk_ime_agents), + cjk_ime_cursor_shape: config.experimental.cjk_ime_cursor_shape.to_decscusr(), kitty_graphics_enabled: config.experimental.kitty_graphics, default_shell: config.terminal.default_shell.clone(), pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, @@ -887,6 +905,11 @@ impl App { if was_kitty_graphics_enabled && !config.experimental.kitty_graphics { let _ = crate::kitty_graphics::clear_all_host_graphics(); } + self.state.reveal_hidden_cursor_for_cjk_ime = + config.experimental.reveal_hidden_cursor_for_cjk_ime; + 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(); } if !invalid_section("advanced") { diff --git a/src/app/state.rs b/src/app/state.rs index 7ee79c4a..1566fdeb 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -965,6 +965,14 @@ pub struct AppState { pub confirm_close: bool, pub prompt_new_tab_name: bool, pub show_agent_labels_on_pane_borders: bool, + /// Expose the focused pane's cursor anchor to the outer terminal even when + /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. + pub reveal_hidden_cursor_for_cjk_ime: bool, + /// Restrict cursor reveal to focused panes whose detected agent matches + /// one of these. Empty means apply to any focused pane. + pub cjk_ime_agents: Vec<crate::detect::Agent>, + /// DECSCUSR shape parameter (1–6) for the IME anchor cursor. + pub cjk_ime_cursor_shape: u8, pub kitty_graphics_enabled: bool, pub default_shell: String, pub pane_scrollback_limit_bytes: usize, @@ -1224,6 +1232,9 @@ impl AppState { confirm_close: true, prompt_new_tab_name: true, show_agent_labels_on_pane_borders: false, + reveal_hidden_cursor_for_cjk_ime: false, + cjk_ime_agents: Vec::new(), + cjk_ime_cursor_shape: 2, // steady_block kitty_graphics_enabled: false, default_shell: String::new(), pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, diff --git a/src/config/model.rs b/src/config/model.rs index f0557072..ba52d579 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -209,6 +209,33 @@ pub struct UiConfig { pub sound: SoundConfig, } +/// Cursor shape (DECSCUSR) used for the forced IME anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImeCursorShape { + Block, + #[default] + SteadyBlock, + Underline, + SteadyUnderline, + Bar, + SteadyBar, +} + +impl ImeCursorShape { + /// Convert to DECSCUSR parameter (1–6). + pub fn to_decscusr(self) -> u8 { + match self { + Self::Block => 1, + Self::SteadyBlock => 2, + Self::Underline => 3, + Self::SteadyUnderline => 4, + Self::Bar => 5, + Self::SteadyBar => 6, + } + } +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct AdvancedConfig { @@ -224,6 +251,27 @@ pub struct ExperimentalConfig { pub allow_nested: bool, /// Experimental local Kitty graphics rendering for attached clients. Default: false. pub kitty_graphics: bool, + /// Expose the focused pane's cursor anchor to the outer terminal even when + /// the pane requested `?25l`, so macOS native input methods keep tracking + /// the candidate window when TUIs paint their own cursor (Claude Code, pi, + /// codex, etc.). Default: false. + /// + /// When the pane reports no cursor position, falls back to the pane's + /// top-left so a stable IME anchor is always available. + /// + /// Trade-off when enabled: an extra hardware cursor will be visible in the + /// outer terminal for apps that hide the cursor without painting a + /// replacement (vim normal mode, etc.). See #149. + pub reveal_hidden_cursor_for_cjk_ime: bool, + /// Restrict `reveal_hidden_cursor_for_cjk_ime` to focused panes whose + /// detected agent matches one of these names (case-insensitive). Empty + /// list means apply to any focused pane. Unknown agent names are ignored. + /// Accepted names: pi, claude, codex, gemini, cursor, cline, opencode, + /// copilot, kimi, kiro, droid, amp, grok, hermes. Default: empty. + pub cjk_ime_agents: Vec<String>, + /// Cursor shape rendered for the IME anchor when + /// `reveal_hidden_cursor_for_cjk_ime` is enabled. Default: "steady_block". + pub cjk_ime_cursor_shape: ImeCursorShape, } impl Default for KeysConfig { @@ -381,6 +429,54 @@ prompt_new_tab_name = false } #[test] + fn reveal_hidden_cursor_for_cjk_ime_default_off_and_parse() { + let default_config = Config::default(); + assert!(!default_config.experimental.reveal_hidden_cursor_for_cjk_ime); + + let toml = r#" +[experimental] +reveal_hidden_cursor_for_cjk_ime = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.experimental.reveal_hidden_cursor_for_cjk_ime); + } + + #[test] + fn cjk_ime_cursor_shape_default_steady_block_and_parse() { + let default_config = Config::default(); + assert_eq!( + default_config.experimental.cjk_ime_cursor_shape, + ImeCursorShape::SteadyBlock + ); + + let toml = r#" +[experimental] +cjk_ime_cursor_shape = "bar" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!( + config.experimental.cjk_ime_cursor_shape, + ImeCursorShape::Bar + ); + } + + #[test] + fn cjk_ime_agents_default_empty_and_parse() { + let default_config = Config::default(); + assert!(default_config.experimental.cjk_ime_agents.is_empty()); + + let toml = r#" +[experimental] +cjk_ime_agents = ["claude", "codex"] +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!( + config.experimental.cjk_ime_agents, + vec!["claude".to_string(), "codex".to_string()] + ); + } + + #[test] fn sidebar_bounds_default_and_parse() { let default_config = Config::default(); assert_eq!(default_config.ui.sidebar_min_width, 18); diff --git a/src/main.rs b/src/main.rs index bbf04df2..440a87a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -202,6 +202,19 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Experimental local Kitty graphics rendering for attached clients. # Requires a Kitty graphics-compatible outer terminal. # kitty_graphics = false +# Expose the focused pane's cursor to the outer terminal so macOS input +# methods keep tracking the candidate window when TUIs paint their own +# cursor (Claude Code, pi, codex). Trade-off: extra cursor visible for +# apps that hide it without painting a replacement (vim normal mode, etc.). +# reveal_hidden_cursor_for_cjk_ime = false +# Optional allow-list: only reveal for focused panes whose detected agent +# matches one of these names. Empty means apply to any focused pane. +# Accepted: pi, claude, codex, gemini, cursor, cline, opencode, copilot, +# kimi, kiro, droid, amp, grok, hermes. +# cjk_ime_agents = [] +# Cursor shape rendered when reveal_hidden_cursor_for_cjk_ime is true. +# Values: block, steady_block (default), underline, steady_underline, bar, steady_bar. +# cjk_ime_cursor_shape = "steady_block" [advanced] # Maximum scrollback buffer size in bytes retained per pane terminal. diff --git a/src/server/headless.rs b/src/server/headless.rs index 49724ffe..f56dbdeb 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2685,6 +2685,149 @@ mod tests { } #[tokio::test] + async fn virtual_render_exposes_hidden_pane_cursor_when_reveal_hidden_for_cjk_ime() { + let mut state = AppState::test_new(); + state.reveal_hidden_cursor_for_cjk_ime = true; + let mut ws = crate::workspace::Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), + ); + + state.workspaces = vec![ws]; + state.active = Some(0); + state.selected = 0; + state.mode = crate::app::Mode::Terminal; + + let area = Rect::new(0, 0, 80, 24); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); + let pane = state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + .expect("focused pane info"); + + assert_eq!( + cursor, + Some(CursorState { + x: pane.inner_rect.x + 4, + y: pane.inner_rect.y, + visible: true, + shape: state.cjk_ime_cursor_shape, + }) + ); + } + + #[tokio::test] + async fn virtual_render_keeps_cursor_hidden_when_scrolled_back_even_with_reveal_hidden_for_cjk_ime( + ) { + let mut state = AppState::test_new(); + state.reveal_hidden_cursor_for_cjk_ime = true; + let mut ws = crate::workspace::Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + let mut bytes = Vec::new(); + for line in 0..80 { + bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes()); + } + let runtime = + crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes); + ws.insert_test_runtime(pane_id, runtime); + + state.workspaces = vec![ws]; + state.active = Some(0); + state.selected = 0; + state.mode = crate::app::Mode::Terminal; + + let area = Rect::new(0, 0, 80, 24); + let _ = crate::server::render_stream::render_virtual(&mut state, area, true); + let runtime = state + .runtime_for_pane(pane_id) + .expect("pane runtime after initial render"); + runtime.scroll_up(6); + assert!(crate::ui::pane_is_scrolled_back(runtime)); + + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); + + assert!( + cursor.as_ref().is_none_or(|cursor| !cursor.visible), + "scrolled-back focused pane should keep the cursor hidden even when reveal_hidden_cursor_for_cjk_ime is true; got {cursor:?}", + ); + } + + #[tokio::test] + async fn virtual_render_fallback_cursor_when_viewport_none_and_reveal_hidden_for_cjk_ime() { + let mut state = AppState::test_new(); + state.reveal_hidden_cursor_for_cjk_ime = true; + let mut ws = crate::workspace::Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + // Feed only ?25l with no prior cursor movement — exercises the fallback + // path for TUIs whose viewport has no cursor position. + ws.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"\x1b[?25l"), + ); + + state.workspaces = vec![ws]; + state.active = Some(0); + state.selected = 0; + state.mode = crate::app::Mode::Terminal; + + let area = Rect::new(0, 0, 80, 24); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); + let pane = state + .view + .pane_infos + .iter() + .find(|info| info.id == pane_id) + .expect("focused pane info"); + + assert_eq!( + cursor, + Some(CursorState { + x: pane.inner_rect.x, + y: pane.inner_rect.y, + visible: true, + shape: state.cjk_ime_cursor_shape, + }), + "fallback should anchor at pane top-left with the configured shape", + ); + } + + #[tokio::test] + async fn virtual_render_skips_reveal_when_focused_pane_has_no_detected_agent() { + let mut state = AppState::test_new(); + state.reveal_hidden_cursor_for_cjk_ime = true; + // Filter only Claude, but the test pane has no detected agent, so the + // reveal must not apply. + state.cjk_ime_agents = vec![crate::detect::Agent::Claude]; + let mut ws = crate::workspace::Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"), + ); + + state.workspaces = vec![ws]; + state.active = Some(0); + state.selected = 0; + state.mode = crate::app::Mode::Terminal; + + let area = Rect::new(0, 0, 80, 24); + let (_buffer, cursor) = + crate::server::render_stream::render_virtual(&mut state, area, true); + + assert!( + cursor.as_ref().is_none_or(|cursor| !cursor.visible), + "agent filter should suppress reveal when the focused pane's detected agent is not on the list; got {cursor:?}", + ); + } + + #[tokio::test] async fn virtual_render_omits_focused_pane_cursor_while_mobile_switcher_open() { let mut state = AppState::test_new(); let mut ws = crate::workspace::Workspace::test_new("test"); diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index d82eb855..6dd4ad48 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -322,11 +322,54 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> { .iter() .find(|info| info.is_focused)?; let rt = app_state.runtime_for_pane_in_workspace(ws_idx, info.id)?; - let cursor = rt.cursor_state(info.inner_rect, true)?; - Some(CursorState { - x: cursor.x, - y: cursor.y, - visible: cursor.visible && !crate::ui::pane_is_scrolled_back(rt), - shape: cursor.shape, - }) + let scrolled_back = crate::ui::pane_is_scrolled_back(rt); + + // Determine whether the IME-anchor reveal applies to this focused pane. + // The master switch must be on, and either no agent filter is configured + // (apply to any pane) or the focused pane's detected agent matches the + // allow-list. + let reveal = app_state.reveal_hidden_cursor_for_cjk_ime + && (app_state.cjk_ime_agents.is_empty() || { + let detected = app_state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(info.id)) + .and_then(|tid| app_state.terminals.get(tid)) + .and_then(|t| t.detected_agent); + detected.is_some_and(|agent| app_state.cjk_ime_agents.contains(&agent)) + }); + + if let Some(cursor) = rt.cursor_state(info.inner_rect, true) { + // When the reveal applies, expose the cursor anchor regardless of the + // pane's `?25l` request so macOS IMEs keep tracking the candidate + // window when TUIs paint their own cursor. Scrollback suppression + // still applies. + let visible = if reveal { + !scrolled_back + } else { + cursor.visible && !scrolled_back + }; + Some(CursorState { + x: cursor.x, + y: cursor.y, + visible, + shape: if reveal && visible { + app_state.cjk_ime_cursor_shape + } else { + cursor.shape + }, + }) + } else if reveal && !scrolled_back { + // cursor_state() returned None — the viewport has no cursor position + // (can happen with complex TUIs). Fall back to the pane's top-left so + // the outer terminal still exposes a cursor anchor for IME tracking. + Some(CursorState { + x: info.inner_rect.x, + y: info.inner_rect.y, + visible: true, + shape: app_state.cjk_ime_cursor_shape, + }) + } else { + None + } } |
