From ebc6522276f2a2136d3908798046c97fee7e06f5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 9 Apr 2026 15:54:24 -0300 Subject: [PATCH] feat(tui): show bar cursor in vim insert mode Thread cursor style through the custom terminal and render tree so the chat composer can request an insert-like cursor while Vim mode is in Insert. Restore the terminal default cursor style on Normal/non-Vim frames and TUI teardown, and cover the escape sequences plus composer mode transitions. --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 54 +++++- codex-rs/tui/src/bottom_pane/footer.rs | 131 +++++++------- codex-rs/tui/src/bottom_pane/mod.rs | 9 +- codex-rs/tui/src/bottom_pane/textarea.rs | 16 +- codex-rs/tui/src/chatwidget.rs | 4 + .../tui/src/chatwidget/tests/plan_mode.rs | 5 +- codex-rs/tui/src/custom_terminal.rs | 168 +++++++++++++++++- codex-rs/tui/src/render/renderable.rs | 72 ++++++++ codex-rs/tui/src/tui.rs | 7 +- 10 files changed, 382 insertions(+), 85 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 29d7899120..a546c7b797 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1136,6 +1136,7 @@ Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.t |frame| { self.chat_widget.render(frame.area(), frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_style(self.chat_widget.cursor_style(frame.area())); frame.set_cursor_position((x, y)); } }, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fb47625231..75b1dce595 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -965,10 +965,6 @@ impl ChatComposer { enabled } - pub(crate) fn is_vim_insert(&self) -> bool { - self.textarea.is_vim_insert() - } - #[cfg(test)] pub(crate) fn is_vim_enabled(&self) -> bool { self.textarea.is_vim_enabled() @@ -3295,8 +3291,6 @@ impl ChatComposer { quit_shortcut_key: self.quit_shortcut_key, collaboration_modes_enabled: self.collaboration_modes_enabled, is_wsl, - context_window_percent: self.context_window_percent, - context_window_used_tokens: self.context_window_used_tokens, status_line_value: self.status_line_value.clone(), status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), @@ -3941,6 +3935,14 @@ impl Renderable for ChatComposer { self.textarea.cursor_pos_with_state(textarea_rect, state) } + fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { + if self.textarea.uses_vim_insert_cursor() { + crossterm::cursor::SetCursorStyle::SteadyBar + } else { + crossterm::cursor::SetCursorStyle::DefaultUserShape + } + } + fn desired_height(&self, width: u16) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self @@ -5226,6 +5228,46 @@ mod tests { assert_eq!(composer.textarea.cursor(), "he".len()); } + #[test] + fn vim_insert_uses_bar_cursor_style() { + use crate::render::renderable::Renderable; + use crossterm::cursor::SetCursorStyle; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use crossterm::queue; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + let area = Rect::new(0, 0, 80, 10); + let style_output = |style| { + let mut output = Vec::new(); + queue!(output, style).expect("queue cursor style"); + output + }; + let default = style_output(SetCursorStyle::DefaultUserShape); + let steady_bar = style_output(SetCursorStyle::SteadyBar); + + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.set_vim_enabled(true); + assert_eq!(style_output(composer.cursor_style(area)), default,); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("hey".to_string(), Vec::new(), Vec::new()); + assert_eq!(style_output(composer.cursor_style(area)), steady_bar); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(style_output(composer.cursor_style(area)), default,); + } + #[test] fn clear_for_ctrl_c_preserves_image_draft_state() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 83e61f3c46..d323a76fed 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -74,8 +74,6 @@ pub(crate) struct FooterProps { /// /// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`. pub(crate) quit_shortcut_key: KeyBinding, - pub(crate) context_window_percent: Option, - pub(crate) context_window_used_tokens: Option, pub(crate) status_line_value: Option>, pub(crate) status_line_enabled: bool, /// Active thread label shown when the footer is rendering contextual information instead of an @@ -1131,11 +1129,27 @@ mod tests { ); } + fn snapshot_footer_with_context( + name: &str, + props: FooterProps, + percent: Option, + used_tokens: Option, + ) { + snapshot_footer_with_mode_indicator_and_context( + name, + /*width*/ 80, + &props, + /*collaboration_mode_indicator*/ None, + context_window_line(percent, used_tokens), + ); + } + fn draw_footer_frame( terminal: &mut Terminal, height: u16, props: &FooterProps, collaboration_mode_indicator: Option, + context_line: Line<'static>, ) { terminal .draw(|f| { @@ -1208,10 +1222,7 @@ mod tests { compact } } else { - Some(context_window_line( - props.context_window_percent, - props.context_window_used_tokens, - )) + Some(context_line.clone()) }; let right_width = right_line .as_ref() @@ -1305,20 +1316,49 @@ mod tests { props: &FooterProps, collaboration_mode_indicator: Option, ) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); - assert_snapshot!(name, terminal.backend()); + snapshot_footer_with_mode_indicator_and_context( + name, + width, + props, + collaboration_mode_indicator, + context_window_line(/*percent*/ None, /*used_tokens*/ None), + ); } - fn render_footer_with_mode_indicator( + fn snapshot_footer_with_mode_indicator_and_context( + name: &str, width: u16, props: &FooterProps, collaboration_mode_indicator: Option, + context_line: Line<'static>, + ) { + let height = footer_height(props).max(1); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + context_line, + ); + assert_snapshot!(name, terminal.backend()); + } + + fn render_footer_with_mode_indicator_and_context( + width: u16, + props: &FooterProps, + collaboration_mode_indicator: Option, + context_line: Line<'static>, ) -> String { let height = footer_height(props).max(1); let mut terminal = Terminal::new(VT100Backend::new(width, height)).expect("terminal"); - draw_footer_frame(&mut terminal, height, props, collaboration_mode_indicator); + draw_footer_frame( + &mut terminal, + height, + props, + collaboration_mode_indicator, + context_line, + ); terminal.backend().vt100().screen().contents() } @@ -1334,8 +1374,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1352,8 +1390,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1370,8 +1406,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1388,8 +1422,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1406,8 +1438,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1424,8 +1454,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1442,15 +1470,13 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, }, ); - snapshot_footer( + snapshot_footer_with_context( "footer_shortcuts_context_running", FooterProps { mode: FooterMode::ComposerEmpty, @@ -1460,15 +1486,15 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(72), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, }, + Some(72), + /*used_tokens*/ None, ); - snapshot_footer( + snapshot_footer_with_context( "footer_context_tokens_used", FooterProps { mode: FooterMode::ComposerEmpty, @@ -1478,12 +1504,12 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: Some(123_456), status_line_value: None, status_line_enabled: false, active_agent_label: None, }, + /*percent*/ None, + Some(123_456), ); snapshot_footer( @@ -1496,8 +1522,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1512,8 +1536,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1541,8 +1563,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -1563,8 +1583,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, @@ -1580,8 +1598,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, @@ -1597,8 +1613,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: None, @@ -1614,18 +1628,17 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, // command timed out / empty status_line_enabled: true, active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_mode_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1636,18 +1649,17 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_disabled_context_right", /*width*/ 120, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1658,19 +1670,18 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: None, status_line_enabled: true, active_agent_label: None, }; // has status line and no collaboration mode - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_enabled_no_mode_right", /*width*/ 120, &props, /*collaboration_mode_indicator*/ None, + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1681,8 +1692,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: Some(Line::from( "Status line content that should truncate before the mode indicator".to_string(), )), @@ -1690,11 +1699,12 @@ mod tests { active_agent_label: None, }; - snapshot_footer_with_mode_indicator( + snapshot_footer_with_mode_indicator_and_context( "footer_status_line_truncated_with_gap", /*width*/ 40, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let props = FooterProps { @@ -1705,8 +1715,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: None, status_line_enabled: false, active_agent_label: Some("Robie [explorer]".to_string()), @@ -1722,8 +1730,6 @@ mod tests { collaboration_modes_enabled: false, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: None, - context_window_used_tokens: None, status_line_value: Some(Line::from("Status line content".to_string())), status_line_enabled: true, active_agent_label: Some("Robie [explorer]".to_string()), @@ -1742,8 +1748,6 @@ mod tests { collaboration_modes_enabled: true, is_wsl: false, quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')), - context_window_percent: Some(50), - context_window_used_tokens: None, status_line_value: Some(Line::from( "Status line content that is definitely too long to fit alongside the mode label" .to_string(), @@ -1752,10 +1756,11 @@ mod tests { active_agent_label: None, }; - let screen = render_footer_with_mode_indicator( + let screen = render_footer_with_mode_indicator_and_context( /*width*/ 80, &props, Some(CollaborationModeIndicator::Plan), + context_window_line(Some(50), /*used_tokens*/ None), ); let collapsed = screen.split_whitespace().collect::>().join(" "); assert!( diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b1cd0e7209..342995fece 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -958,11 +958,6 @@ impl BottomPane { self.composer.is_empty() } - #[cfg(test)] - pub(crate) fn composer_is_vim_insert(&self) -> bool { - self.composer.is_vim_insert() - } - #[cfg(test)] pub(crate) fn composer_is_vim_enabled(&self) -> bool { self.composer.is_vim_enabled() @@ -1377,6 +1372,10 @@ impl Renderable for BottomPane { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.as_renderable().cursor_style(area) + } } #[cfg(test)] diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 09fac76421..0e900819ae 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -237,7 +237,7 @@ impl TextArea { !self.vim_enabled || self.vim_mode == VimMode::Insert } - pub(crate) fn is_vim_insert(&self) -> bool { + pub(crate) fn uses_vim_insert_cursor(&self) -> bool { self.vim_enabled && self.vim_mode == VimMode::Insert } @@ -1987,7 +1987,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(t.text(), "h"); - assert!(!t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Normal")); assert_eq!(t.cursor(), 0); } @@ -1999,7 +1999,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Normal")); assert_eq!(t.cursor(), 0); } @@ -2012,7 +2012,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Normal")); assert_eq!(t.cursor(), "👍".len()); } @@ -2026,7 +2026,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); t.input(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Normal")); assert_eq!(t.cursor(), 1); } @@ -2039,7 +2039,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('I'), KeyModifiers::NONE)); - assert!(t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Insert")); assert_eq!(t.cursor(), 6); } @@ -2052,7 +2052,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)); - assert!(t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Insert")); assert_eq!(t.cursor(), 11); } @@ -2066,7 +2066,7 @@ mod tests { t.input(KeyEvent::new(KeyCode::Char('O'), KeyModifiers::NONE)); assert_eq!(t.text(), "hello\n\nworld"); - assert!(t.is_vim_insert()); + assert_eq!(t.vim_mode_label(), Some("Insert")); assert_eq!(t.cursor(), 6); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c545e905f3..042cde8d4e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11884,6 +11884,10 @@ impl Renderable for ChatWidget { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.as_renderable().cursor_pos(area) } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.as_renderable().cursor_style(area) + } } #[derive(Debug)] diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index b9f4cee621..d7d74ff6ac 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1369,7 +1369,10 @@ async fn vim_mode_default_enabled_starts_composer_in_normal_mode() { .await; assert!(chat.bottom_pane.composer_is_vim_enabled()); - assert!(!chat.bottom_pane.composer_is_vim_insert()); + assert!(chat.composer_is_empty()); + let mut chat = chat; + chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), ""); } async fn make_startup_chat_with_cli_overrides( diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 556992b805..09220ff611 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -25,6 +25,7 @@ use std::io; use std::io::Write; use crossterm::cursor::MoveTo; +use crossterm::cursor::SetCursorStyle; use crossterm::queue; use crossterm::style::Colors; use crossterm::style::Print; @@ -78,7 +79,6 @@ fn display_width(s: &str) -> usize { visible.width() } -#[derive(Debug, Hash)] pub struct Frame<'a> { /// Where should the cursor be after drawing this frame? /// @@ -86,6 +86,9 @@ pub struct Frame<'a> { /// y))`, the cursor is shown and placed at `(x, y)` after the call to `Terminal::draw()`. pub(crate) cursor_position: Option, + /// Visible cursor shape to apply after drawing this frame. + cursor_style: SetCursorStyle, + /// The area of the viewport pub(crate) viewport_area: Rect, @@ -128,6 +131,11 @@ impl Frame<'_> { self.cursor_position = Some(position.into()); } + /// After drawing this frame, set the terminal's visible cursor style. + pub fn set_cursor_style(&mut self, style: SetCursorStyle) { + self.cursor_style = style; + } + /// Gets the buffer that this `Frame` draws into as a mutable reference. pub fn buffer_mut(&mut self) -> &mut Buffer { self.buffer @@ -167,6 +175,10 @@ where #[allow(clippy::print_stderr)] fn drop(&mut self) { // Attempt to restore the cursor state + if let Err(err) = self.reset_cursor_style() { + eprintln!("Failed to reset the cursor style: {err}"); + } + if self.hidden_cursor && let Err(err) = self.show_cursor() { @@ -205,6 +217,7 @@ where pub fn get_frame(&mut self) -> Frame<'_> { Frame { cursor_position: None, + cursor_style: SetCursorStyle::DefaultUserShape, viewport_area: self.viewport_area, buffer: self.current_buffer_mut(), } @@ -362,6 +375,7 @@ where // stdout first. But we also can't keep the frame around, since it holds a &mut to // Buffer. Thus, we're taking the important data out of the Frame and dropping it. let cursor_position = frame.cursor_position; + let cursor_style = frame.cursor_style; // Draw to stdout self.flush()?; @@ -369,6 +383,7 @@ where match cursor_position { None => self.hide_cursor()?, Some(position) => { + self.set_cursor_style(cursor_style)?; self.show_cursor()?; self.set_cursor_position(position)?; } @@ -395,6 +410,16 @@ where Ok(()) } + /// Sets the visible terminal cursor style. + pub fn set_cursor_style(&mut self, style: SetCursorStyle) -> io::Result<()> { + queue!(self.backend, style) + } + + /// Restores the user-configured terminal cursor style. + pub fn reset_cursor_style(&mut self) -> io::Result<()> { + self.set_cursor_style(SetCursorStyle::DefaultUserShape) + } + /// Gets the current cursor position. /// /// This is the position of the cursor after the last draw call. @@ -708,9 +733,110 @@ impl ModifierDiff { mod tests { use super::*; use pretty_assertions::assert_eq; + use ratatui::backend::WindowSize; use ratatui::layout::Rect; use ratatui::style::Style; + struct CaptureBackend { + output: Vec, + size: Size, + cursor: Position, + } + + impl CaptureBackend { + fn new(width: u16, height: u16) -> Self { + Self { + output: Vec::new(), + size: Size { width, height }, + cursor: Position { x: 0, y: 0 }, + } + } + + fn output(&self) -> String { + String::from_utf8_lossy(&self.output).into_owned() + } + } + + impl Write for CaptureBackend { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.output.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl Backend for CaptureBackend { + fn draw<'a, I>(&mut self, _content: I) -> io::Result<()> + where + I: Iterator, + { + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + Ok(()) + } + + fn show_cursor(&mut self) -> io::Result<()> { + Ok(()) + } + + fn get_cursor_position(&mut self) -> io::Result { + Ok(self.cursor) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + self.cursor = position.into(); + Ok(()) + } + + fn clear(&mut self) -> io::Result<()> { + Ok(()) + } + + fn clear_region(&mut self, _clear_type: ClearType) -> io::Result<()> { + Ok(()) + } + + fn append_lines(&mut self, _line_count: u16) -> io::Result<()> { + Ok(()) + } + + fn scroll_region_up( + &mut self, + _region: std::ops::Range, + _scroll_by: u16, + ) -> io::Result<()> { + Ok(()) + } + + fn scroll_region_down( + &mut self, + _region: std::ops::Range, + _scroll_by: u16, + ) -> io::Result<()> { + Ok(()) + } + + fn size(&self) -> io::Result { + Ok(self.size) + } + + fn window_size(&mut self) -> io::Result { + Ok(WindowSize { + columns_rows: self.size, + pixels: self.size, + }) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { let area = Rect::new(0, 0, 3, 2); @@ -756,4 +882,44 @@ mod tests { "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" ); } + + #[test] + fn terminal_draw_applies_requested_cursor_style() { + let mut output = Vec::new(); + let mut terminal = Terminal::with_options(CaptureBackend::new(2, 1)).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 2, 1)); + + terminal + .try_draw(|frame| { + frame.set_cursor_style(SetCursorStyle::SteadyBar); + frame.set_cursor_position((0, 0)); + io::Result::Ok(()) + }) + .expect("draw"); + + queue!(output, SetCursorStyle::SteadyBar).expect("queue style"); + let expected = String::from_utf8(output).expect("utf8"); + let actual = terminal.backend().output(); + assert!( + actual.contains(&expected), + "expected terminal output to contain cursor style {expected:?}, got {actual:?}" + ); + } + + #[test] + fn reset_cursor_style_emits_default_user_shape() { + let mut output = Vec::new(); + let mut terminal = Terminal::with_options(CaptureBackend::new(2, 1)).expect("terminal"); + + terminal.reset_cursor_style().expect("reset cursor style"); + ratatui::backend::Backend::flush(terminal.backend_mut()).expect("flush backend"); + + queue!(output, SetCursorStyle::DefaultUserShape).expect("queue style"); + let expected = String::from_utf8(output).expect("utf8"); + let actual = terminal.backend().output(); + assert!( + actual.contains(&expected), + "expected terminal output to contain cursor style reset {expected:?}, got {actual:?}" + ); + } } diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index 2bb78a3cde..6455b0f78b 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crossterm::cursor::SetCursorStyle; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; @@ -16,6 +17,9 @@ pub trait Renderable { fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { None } + fn cursor_style(&self, _area: Rect) -> SetCursorStyle { + SetCursorStyle::DefaultUserShape + } } pub enum RenderableItem<'a> { @@ -44,6 +48,13 @@ impl<'a> Renderable for RenderableItem<'a> { RenderableItem::Borrowed(child) => child.cursor_pos(area), } } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + match self { + RenderableItem::Owned(child) => child.cursor_style(area), + RenderableItem::Borrowed(child) => child.cursor_style(area), + } + } } impl<'a> From> for RenderableItem<'a> { @@ -127,6 +138,18 @@ impl Renderable for Option { 0 } } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_ref() + .and_then(|renderable| renderable.cursor_pos(area)) + } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.as_ref() + .map_or(SetCursorStyle::DefaultUserShape, |renderable| { + renderable.cursor_style(area) + }) + } } impl Renderable for Arc { @@ -136,6 +159,12 @@ impl Renderable for Arc { fn desired_height(&self, width: u16) -> u16 { self.as_ref().desired_height(width) } + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.as_ref().cursor_pos(area) + } + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.as_ref().cursor_style(area) + } } pub struct ColumnRenderable<'a> { @@ -180,6 +209,19 @@ impl Renderable for ColumnRenderable<'_> { } None } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + let mut y = area.y; + for child in &self.children { + let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width)) + .intersection(area); + if !child_area.is_empty() && child.cursor_pos(child_area).is_some() { + return child.cursor_style(child_area); + } + y += child_area.height; + } + SetCursorStyle::DefaultUserShape + } } impl<'a> ColumnRenderable<'a> { @@ -304,6 +346,19 @@ impl<'a> Renderable for FlexRenderable<'a> { .zip(self.children.iter()) .find_map(|(rect, child)| child.child.cursor_pos(rect)) } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.allocate(area) + .into_iter() + .zip(self.children.iter()) + .find_map(|(rect, child)| { + child + .child + .cursor_pos(rect) + .map(|_| child.child.cursor_style(rect)) + }) + .unwrap_or(SetCursorStyle::DefaultUserShape) + } } pub struct RowRenderable<'a> { @@ -354,6 +409,19 @@ impl Renderable for RowRenderable<'_> { } None } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + let mut x = area.x; + for (width, child) in &self.children { + let available_width = area.width.saturating_sub(x - area.x); + let child_area = Rect::new(x, area.y, (*width).min(available_width), area.height); + if !child_area.is_empty() && child.cursor_pos(child_area).is_some() { + return child.cursor_style(child_area); + } + x = x.saturating_add(*width); + } + SetCursorStyle::DefaultUserShape + } } impl<'a> RowRenderable<'a> { @@ -385,6 +453,10 @@ impl<'a> Renderable for InsetRenderable<'a> { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { self.child.cursor_pos(area.inset(self.insets)) } + + fn cursor_style(&self, area: Rect) -> SetCursorStyle { + self.child.cursor_style(area.inset(self.insets)) + } } impl<'a> InsetRenderable<'a> { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 291a8ca63c..8db002b2cf 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -14,6 +14,7 @@ use std::time::Duration; use crossterm::Command; use crossterm::SynchronizedUpdate; +use crossterm::cursor::SetCursorStyle; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; @@ -342,7 +343,11 @@ fn restore_common(should_disable_raw_mode: bool) -> Result<()> { if should_disable_raw_mode { disable_raw_mode()?; } - let _ = execute!(stdout(), crossterm::cursor::Show); + let _ = execute!( + stdout(), + SetCursorStyle::DefaultUserShape, + crossterm::cursor::Show + ); Ok(()) }