aboutsummaryrefslogtreecommitdiff
path: root/src/server
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 /src/server
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
Diffstat (limited to 'src/server')
-rw-r--r--src/server/headless.rs143
-rw-r--r--src/server/render_stream.rs57
2 files changed, 193 insertions, 7 deletions
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
+ }
}