diff options
| author | ChihGodlee <61595521+ChihGodlee@users.noreply.github.com> | 2026-05-20 20:05:38 +0800 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-05-21 03:10:52 +0300 |
| commit | 6172275252f5f724d874163462f5394800f8d55b (patch) | |
| tree | 86ff28c99faf9e20b74a75ee0abcd503e44e06db /src/server | |
| parent | d1576ade07a7236996869d78f37a4aee00367b42 (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.rs | 143 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 57 |
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 + } } |
