aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs23
-rw-r--r--src/app/state.rs11
-rw-r--r--src/config/model.rs96
-rw-r--r--src/main.rs13
-rw-r--r--src/server/headless.rs143
-rw-r--r--src/server/render_stream.rs57
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
+ }
}