aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChihGodlee <61595521+ChihGodlee@users.noreply.github.com>2026-05-20 20:05:38 +0800
committerCan Celik <ogulcancelik@gmail.com>2026-05-21 03:10:52 +0300
commit6172275252f5f724d874163462f5394800f8d55b (patch)
tree86ff28c99faf9e20b74a75ee0abcd503e44e06db
parentd1576ade07a7236996869d78f37a4aee00367b42 (diff)
feat(experimental): add reveal_hidden_cursor_for_cjk_ime opt-in for CJK IME tracking
When the focused pane requests ?25l (cursor hidden) but paints its own cursor — common in TUIs like Claude Code, pi, and codex — macOS native input methods stop tracking the candidate-window position. CJK users (Chinese / Japanese / Korean) cannot see candidate windows follow as they type. Setting [experimental] reveal_hidden_cursor_for_cjk_ime = true exposes the focused pane's cursor anchor to the outer terminal regardless of the pane's hidden-cursor request, restoring IME tracking for CJK input. [experimental] cjk_ime_agents is an optional allow-list. When empty, the reveal applies to any focused pane. When non-empty (e.g. ["claude", "codex"]), the reveal only applies if the focused pane's detected agent matches one of the listed names. This lets users enable the workaround only for AI-agent TUIs that paint their own cursor, leaving plain shells and other programs untouched. Accepted names: pi, claude, codex, gemini, cursor, cline, opencode, copilot, kimi, kiro, droid, amp, grok, hermes. Unknown names are ignored. When the pane reports no cursor position (cursor_state returns None for some TUIs), the anchor falls back to the pane's top-left so a stable IME hint is always available. [experimental] cjk_ime_cursor_shape (default "steady_block") controls the DECSCUSR shape rendered for the revealed cursor; six variants are accepted: block, steady_block, underline, steady_underline, bar, steady_bar. Trade-off when enabled: a hardware cursor will be visible in the outer terminal for apps that hide the cursor without painting a replacement. Default false preserves the behavior set in 2c95071. Pair the reveal with cjk_ime_agents to scope it to specific TUIs. Manually verified on macOS 26.5 aarch64 with system Pinyin IME against Claude Code: candidate window tracks cursor with flag enabled, returns to anchored-at-start behavior with flag disabled. refs #149
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx23
-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
8 files changed, 360 insertions, 7 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index d2457878..f504ac25 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -14,6 +14,7 @@
- Added update badges on the sidebar menu, settings menu item, and integrations settings tab when installed integrations are outdated.
- Added `terminal.default_shell` to choose the executable used for new interactive panes. When unset, Herdr still falls back to `$SHELL`, then `/bin/sh`. (#196)
- Added native Kiro CLI detection with idle and working state heuristics. (#185)
+- Added `experimental.reveal_hidden_cursor_for_cjk_ime = false` (opt-in), `experimental.cjk_ime_agents = []` (optional allow-list), and `experimental.cjk_ime_cursor_shape = "steady_block"` to expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, restoring macOS IME candidate-window tracking for TUIs that paint their own cursor (Claude Code, pi, codex). When `cjk_ime_agents` is non-empty, the reveal applies only to focused panes whose detected agent matches one of the listed names. When the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available. Trade-off when enabled: an extra hardware cursor may appear in the outer terminal for apps that hide the cursor without painting a replacement. (#149)
### Fixed
- Keybinding conflict warnings now stay visible and show one readable yellow row per conflicting binding.
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 9df7e41d..a026166a 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -284,6 +284,29 @@ kitty_graphics = false
Leave this off unless you are testing terminal image behavior.
+## IME cursor tracking
+
+When the focused pane hides its cursor and paints its own — common in AI-agent TUIs like Claude Code, pi, and codex — macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor.
+
+Set `reveal_hidden_cursor_for_cjk_ime = true` to expose the focused pane's cursor anchor to the outer terminal regardless of the pane's `?25l` request:
+
+```toml
+[experimental]
+reveal_hidden_cursor_for_cjk_ime = false
+cjk_ime_agents = []
+cjk_ime_cursor_shape = "steady_block"
+```
+
+When enabled, the cursor stays visible at the focused pane's reported position. If the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available.
+
+`cjk_ime_agents` is an optional allow-list. When empty, the reveal applies to any focused pane. When non-empty, the reveal only applies if the focused pane's detected agent matches one of the listed names — useful to enable the reveal only for AI-agent TUIs that paint their own cursor while leaving plain shells untouched. Accepted names: `pi`, `claude`, `codex`, `gemini`, `cursor`, `cline`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`. Unknown names are ignored.
+
+`cjk_ime_cursor_shape` controls the DECSCUSR shape rendered for the IME anchor. Accepted values: `block`, `steady_block` (default), `underline`, `steady_underline`, `bar`, `steady_bar`.
+
+Hot-reloads through the existing `[experimental]` block.
+
+The trade-off when enabled: an extra hardware cursor is visible in the outer terminal for apps that hide the cursor without painting a replacement (vim normal mode, etc.). Pair the reveal with `cjk_ime_agents` to scope it to specific TUIs.
+
## Environment variables
| Variable | Purpose |
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
+ }
}