From 293c8af02d1cd4401509de023df3af549b5d412f Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 13 Feb 2026 16:29:45 -0800 Subject: [PATCH] iteration --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 133 ++++++++++++++++-- codex-rs/tui/src/bottom_pane/footer.rs | 61 +++++++- codex-rs/tui/src/bottom_pane/mod.rs | 5 +- codex-rs/tui/src/bottom_pane/textarea.rs | 14 ++ codex-rs/tui/src/chatwidget.rs | 9 +- .../tui/src/public_widgets/composer_input.rs | 4 +- codex-rs/tui/src/slash_command.rs | 4 +- 7 files changed, 208 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f854a41931..36aacd0519 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -115,7 +115,7 @@ //! The composer supports Emacs-style input (default) and Vim-style modal input. Vim mode uses the //! textarea's normal/insert states; paste-burst detection is disabled while in Vim normal mode so //! rapid command keystrokes are not buffered as paste. -use crate::bottom_pane::footer::mode_indicator_line; +use crate::bottom_pane::footer::mode_indicator_line as collaboration_mode_indicator_line; use crate::bottom_pane::selection_popup_common::truncate_line_with_ellipsis_if_overflow; use crate::key_hint; use crate::key_hint::KeyBinding; @@ -778,6 +778,50 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = !self.textarea.is_vim_enabled(); + self.set_vim_enabled(enabled); + enabled + } + + fn vim_mode_indicator_span(&self) -> Option> { + self.textarea.vim_mode_label().map(|label| match label { + "Normal" => "Vim: Normal".yellow(), + "Insert" => "Vim: Insert".green(), + _ => unreachable!(), + }) + } + + fn mode_indicator_line(&self, show_cycle_hint: bool) -> Option> { + let mut spans: Vec> = Vec::new(); + if let Some(vim_mode) = self.vim_mode_indicator_span() { + spans.push(vim_mode); + } + if let Some(collab) = + collaboration_mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint) + { + if !spans.is_empty() { + spans.push(" | ".dim().into()); + } + spans.extend(collab.spans); + } + if spans.is_empty() { + None + } else { + Some(Line::from(spans)) + } + } + + fn right_footer_line_with_context(&self) -> Line<'static> { + let mut line = + context_window_line(self.context_window_percent, self.context_window_used_tokens); + if let Some(vim_mode) = self.vim_mode_indicator_span() { + line.spans.push(" | ".dim().into()); + line.spans.push(vim_mode); + } + line + } + pub(crate) fn current_text_with_pending(&self) -> String { let mut text = self.textarea.text().to_string(); for (placeholder, actual) in &self.pending_pastes { @@ -2522,6 +2566,9 @@ impl ChatComposer { if self.handle_shortcut_overlay_key(&key_event) { return (InputResult::None, true); } + if key_event.code == KeyCode::Esc && self.textarea.is_vim_insert() { + return self.handle_input_basic(key_event); + } if key_event.code == KeyCode::Esc { if self.is_empty() && !self.textarea.is_vim_insert() { let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); @@ -3569,9 +3616,8 @@ impl ChatComposer { ) }; let right_line = if status_line_active { - let full = - mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint); - let compact = mode_indicator_line(self.collaboration_mode_indicator, false); + let full = self.mode_indicator_line(show_cycle_hint); + let compact = self.mode_indicator_line(false); let full_width = full.as_ref().map(|l| l.width() as u16).unwrap_or(0); if can_show_left_with_context(hint_rect, left_width, full_width) { full @@ -3579,10 +3625,7 @@ impl ChatComposer { compact } } else { - Some(context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - )) + Some(self.right_footer_line_with_context()) }; let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); if status_line_active @@ -3624,9 +3667,7 @@ impl ChatComposer { }; let show_right = if matches!( footer_props.mode, - FooterMode::EscHint - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay + FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay ) { false } else { @@ -4376,6 +4417,76 @@ mod tests { } } + #[test] + fn vim_mode_stays_enabled_after_submission() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + 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, + ); + composer.set_steer_enabled(true); + composer.set_vim_enabled(true); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".yellow()) + ); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + composer.set_text_content("h".to_string(), Vec::new(), Vec::new()); + let (result, _) = composer.handle_submission(false); + + assert!(composer.textarea.is_vim_enabled()); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + assert!(composer.is_empty()); + match result { + InputResult::Submitted { text, .. } => assert_eq!(text, "h"), + _ => panic!("expected Submitted"), + } + } + + #[test] + fn esc_switches_vim_insert_to_normal() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + 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, + ); + composer.set_vim_enabled(true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Insert".green()) + ); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + composer.vim_mode_indicator_span(), + Some("Vim: Normal".yellow()) + ); + } + #[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 f6f61acf4b..148644ab9b 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -597,7 +597,11 @@ fn footer_from_props_lines( }; shortcut_overlay_lines(state) } - FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], + FooterMode::EscHint => vec![esc_hint_left_line( + props.esc_backtrack_hint, + collaboration_mode_indicator, + show_cycle_hint, + )], FooterMode::ComposerHasDraft => { let state = LeftSideState { hint: if show_queue_hint { @@ -678,6 +682,19 @@ fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { } } +fn esc_hint_left_line( + esc_backtrack_hint: bool, + collaboration_mode_indicator: Option, + show_cycle_hint: bool, +) -> Line<'static> { + let mut line = esc_hint_line(esc_backtrack_hint); + if let Some(collaboration_mode_indicator) = collaboration_mode_indicator { + line.push_span(" ยท ".dim()); + line.push_span(collaboration_mode_indicator.styled_span(show_cycle_hint)); + } + line +} + fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); @@ -1145,9 +1162,7 @@ mod tests { let show_context = can_show_left_and_context && !matches!( props.mode, - FooterMode::EscHint - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay + FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay ); if show_context && let Some(line) = &right_line { render_context_right(area, f.buffer_mut(), line); @@ -1577,6 +1592,44 @@ mod tests { ); } + #[test] + fn footer_esc_hint_keeps_right_context() { + let props = FooterProps { + mode: FooterMode::EscHint, + esc_backtrack_hint: true, + use_shift_enter_hint: false, + is_task_running: false, + steer_enabled: false, + 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: false, + }; + + let screen = render_footer_with_mode_indicator(120, &props, None); + let expected_context = context_window_line( + props.context_window_percent, + props.context_window_used_tokens, + ); + let expected_context_text = expected_context + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!( + screen.contains("again to edit previous message"), + "left esc hint should be visible" + ); + assert!( + screen.contains(&expected_context_text), + "right-side context should still be visible during Esc hint" + ); + } + #[test] fn paste_image_shortcut_prefers_ctrl_alt_v_under_wsl() { let descriptor = SHORTCUTS diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2ea16f79ae..947a9166ac 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -287,9 +287,10 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_vim_enabled(&mut self, enabled: bool) { - self.composer.set_vim_enabled(enabled); + pub(crate) fn toggle_vim_enabled(&mut self) -> bool { + let enabled = self.composer.toggle_vim_enabled(); self.request_redraw(); + enabled } pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 172e2b7330..a0f0289e46 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -140,6 +140,10 @@ impl TextArea { }; } + pub(crate) fn is_vim_enabled(&self) -> bool { + self.vim_enabled + } + pub(crate) fn allows_paste_burst(&self) -> bool { !self.vim_enabled || self.vim_mode == VimMode::Insert } @@ -148,6 +152,16 @@ impl TextArea { self.vim_enabled && self.vim_mode == VimMode::Insert } + pub(crate) fn vim_mode_label(&self) -> Option<&'static str> { + if !self.vim_enabled { + return None; + } + Some(match self.vim_mode { + VimMode::Normal => "Normal", + VimMode::Insert => "Insert", + }) + } + pub fn text(&self) -> &str { &self.text } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8184a53084..a055f33ebf 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3325,8 +3325,13 @@ impl ChatWidget { self.open_permissions_popup(); } SlashCommand::Vim => { - self.bottom_pane.set_vim_enabled(true); - self.add_info_message("Composer input set to vim mode.".to_string(), None); + let enabled = self.bottom_pane.toggle_vim_enabled(); + let message = if enabled { + "Vim mode enabled." + } else { + "Vim mode disabled." + }; + self.add_info_message(message.to_string(), None); } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 130bbb7d32..f6aef474c7 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -124,7 +124,9 @@ impl ComposerInput { fn handle_slash_command(&mut self, cmd: SlashCommand) { match cmd { - SlashCommand::Vim => self.inner.set_vim_enabled(true), + SlashCommand::Vim => { + self.inner.toggle_vim_enabled(); + } _ => {} } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 17ff4b1991..83fae5b4ed 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,8 +15,8 @@ pub enum SlashCommand { Model, Approvals, Permissions, - #[strum(serialize = "setup-default-sandbox")] Vim, + #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] SandboxReadRoot, @@ -87,7 +87,7 @@ impl SlashCommand { SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", - SlashCommand::Vim => "enable vim key bindings for the composer", + SlashCommand::Vim => "toggle vim key bindings for the composer", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir "