From f39d31f12332dbcadd166d8dd9b5b5c91aaf522b Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 7 Oct 2025 10:54:26 +0530 Subject: [PATCH 1/7] wip: vim motions with vim modes and keybindings --- codex-rs/core/src/config.rs | 13 + codex-rs/core/src/config_types.rs | 12 + codex-rs/tui/src/app.rs | 5 +- codex-rs/tui/src/app_backtrack.rs | 5 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 709 +++++++++++++++++- codex-rs/tui/src/bottom_pane/footer.rs | 53 +- codex-rs/tui/src/bottom_pane/mod.rs | 38 +- codex-rs/tui/src/bottom_pane/textarea.rs | 122 +++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/pager_overlay.rs | 245 +++++- .../tui/src/public_widgets/composer_input.rs | 10 +- 12 files changed, 1150 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 28ad84ba7a..d72da5a1b1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -5,6 +5,7 @@ use crate::config_loader::merge_toml_values; use crate::config_profile::ConfigProfile; use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; +use crate::config_types::KeybindingMode; use crate::config_types::McpServerConfig; use crate::config_types::McpServerTransportConfig; use crate::config_types::Notifications; @@ -134,6 +135,9 @@ pub struct Config { /// and turn completions when not focused. pub tui_notifications: Notifications, + /// Preferred keybinding mode for interactive TUI components. + pub keybinding_mode: KeybindingMode, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1132,6 +1136,11 @@ impl Config { .as_ref() .map(|t| t.notifications.clone()) .unwrap_or_default(), + keybinding_mode: cfg + .tui + .as_ref() + .map(|t| t.keybinding_mode) + .unwrap_or_default(), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -1921,6 +1930,7 @@ model_verbosity = "high" windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), + keybinding_mode: KeybindingMode::default(), otel: OtelConfig::default(), }, o3_profile_config @@ -1983,6 +1993,7 @@ model_verbosity = "high" windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), + keybinding_mode: KeybindingMode::default(), otel: OtelConfig::default(), }; @@ -2060,6 +2071,7 @@ model_verbosity = "high" windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), + keybinding_mode: KeybindingMode::default(), otel: OtelConfig::default(), }; @@ -2123,6 +2135,7 @@ model_verbosity = "high" windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), + keybinding_mode: KeybindingMode::default(), otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 1b8f3ac067..48915759c5 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -33,6 +33,14 @@ pub struct McpServerConfig { pub tool_timeout_sec: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum KeybindingMode { + #[default] + Emacs, + Vim, +} + impl<'de> Deserialize<'de> for McpServerConfig { fn deserialize(deserializer: D) -> Result where @@ -299,6 +307,10 @@ pub struct Tui { /// Defaults to `false`. #[serde(default)] pub notifications: Notifications, + + /// Keybinding style to use throughout the TUI (e.g. composer, transcript pager). + #[serde(default)] + pub keybinding_mode: KeybindingMode, } #[derive(Deserialize, Debug, Clone, PartialEq, Default)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..30a70c9423 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -423,7 +423,10 @@ impl App { } => { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.config.keybinding_mode, + )); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b5c1300b45..15f79e462d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -110,7 +110,10 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.config.keybinding_mode, + )); tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ad9770726a..da39567534 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -21,6 +21,7 @@ use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use super::footer::FooterMode; use super::footer::FooterProps; +use super::footer::VimStatus; use super::footer::esc_hint_mode; use super::footer::footer_height; use super::footer::render_footer; @@ -35,10 +36,12 @@ use crate::bottom_pane::prompt_args::parse_slash_name; use crate::bottom_pane::prompt_args::prompt_argument_names; use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; +use crate::bottom_pane::textarea::VisualSelectionMode; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; use crate::terminal_palette; +use codex_core::config_types::KeybindingMode; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -110,6 +113,8 @@ pub(crate) struct ChatComposer { footer_mode: FooterMode, footer_hint_override: Option>, context_window_percent: Option, + keybinding_mode: KeybindingMode, + vim: Option, } /// Popup state – at most one can be visible at any time. @@ -121,6 +126,50 @@ enum ActivePopup { const FOOTER_SPACING_HEIGHT: u16 = 0; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimMode { + Insert, + Normal, + Visual, + VisualLine, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimPending { + G, + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimDeleteMotion { + ToNextWord, + ToLineStart, + ToLineEnd, +} + +#[derive(Debug)] +struct VimState { + mode: VimMode, + pending: Option, + clipboard: Option, +} + +impl VimState { + fn new() -> Self { + Self { + mode: VimMode::Insert, + pending: None, + clipboard: None, + } + } +} + +impl Default for VimState { + fn default() -> Self { + Self::new() + } +} + impl ChatComposer { pub fn new( has_input_focus: bool, @@ -128,6 +177,24 @@ impl ChatComposer { enhanced_keys_supported: bool, placeholder_text: String, disable_paste_burst: bool, + ) -> Self { + Self::new_with_keybinding_mode( + has_input_focus, + app_event_tx, + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + KeybindingMode::Emacs, + ) + } + + pub fn new_with_keybinding_mode( + has_input_focus: bool, + app_event_tx: AppEventSender, + enhanced_keys_supported: bool, + placeholder_text: String, + disable_paste_burst: bool, + keybinding_mode: KeybindingMode, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; @@ -153,6 +220,8 @@ impl ChatComposer { footer_mode: FooterMode::ShortcutPrompt, footer_hint_override: None, context_window_percent: None, + keybinding_mode, + vim: (keybinding_mode == KeybindingMode::Vim).then(VimState::new), }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -269,6 +338,33 @@ impl ChatComposer { true } + fn reconcile_placeholders_and_attachments(&mut self) { + let text_after = self.textarea.text(); + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + + if !self.attached_images.is_empty() { + let mut needed: HashMap = HashMap::new(); + for img in &self.attached_images { + needed + .entry(img.placeholder.clone()) + .or_insert_with(|| text_after.matches(&img.placeholder).count()); + } + + let mut used: HashMap = HashMap::new(); + let mut kept: Vec = Vec::with_capacity(self.attached_images.len()); + for img in self.attached_images.drain(..) { + let total_needed = *needed.get(&img.placeholder).unwrap_or(&0); + let used_count = used.entry(img.placeholder.clone()).or_insert(0); + if *used_count < total_needed { + kept.push(img); + *used_count += 1; + } + } + self.attached_images = kept; + } + } + pub fn handle_paste_image_path(&mut self, pasted: String) -> bool { let Some(path_buf) = normalize_pasted_path(&pasted) else { return false; @@ -296,6 +392,228 @@ impl ChatComposer { } } + fn vim_enabled(&self) -> bool { + self.keybinding_mode == KeybindingMode::Vim + } + + fn current_vim_mode(&self) -> Option { + self.vim.as_ref().map(|v| v.mode) + } + + fn vim_state_mut(&mut self) -> Option<&mut VimState> { + self.vim.as_mut() + } + + fn vim_pending(&self) -> Option { + self.vim.as_ref().and_then(|v| v.pending) + } + + fn clear_vim_pending(&mut self) { + if let Some(vim) = self.vim_state_mut() { + vim.pending = None; + } + } + + fn set_vim_pending(&mut self, pending: VimPending) { + if let Some(vim) = self.vim_state_mut() { + vim.pending = Some(pending); + } + } + + fn take_vim_pending(&mut self) -> Option { + self.vim_state_mut()?.pending.take() + } + + fn set_vim_clipboard(&mut self, text: String) { + if let Some(vim) = self.vim_state_mut() { + vim.clipboard = Some(text); + } + } + + fn vim_clipboard(&self) -> Option<&str> { + self.vim.as_ref()?.clipboard.as_deref() + } + + fn enter_vim_normal_mode(&mut self) { + if let Some(vim) = self.vim_state_mut() { + vim.mode = VimMode::Normal; + vim.pending = None; + } + self.textarea.clear_selection(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + fn enter_vim_insert_mode(&mut self) { + if let Some(vim) = self.vim_state_mut() { + vim.mode = VimMode::Insert; + vim.pending = None; + } + self.textarea.clear_selection(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + fn enter_visual_mode(&mut self) { + if let Some(vim) = self.vim_state_mut() { + vim.mode = VimMode::Visual; + vim.pending = None; + } + if !self.textarea.has_selection() { + self.textarea + .start_selection(VisualSelectionMode::Character); + } else { + self.textarea + .update_selection_mode(VisualSelectionMode::Character); + } + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + fn enter_visual_line_mode(&mut self) { + if let Some(vim) = self.vim_state_mut() { + vim.mode = VimMode::VisualLine; + vim.pending = None; + } + if !self.textarea.has_selection() { + self.textarea.start_selection(VisualSelectionMode::Line); + } else { + self.textarea + .update_selection_mode(VisualSelectionMode::Line); + } + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + fn after_vim_cursor_motion(&mut self) { + self.clear_vim_pending(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + fn after_vim_text_edit(&mut self) { + self.clear_vim_pending(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.paste_burst.clear_window_after_non_char(); + self.reconcile_placeholders_and_attachments(); + } + + fn delete_char_under_cursor(&mut self) -> bool { + if self.textarea.cursor() >= self.textarea.text().len() && !self.textarea.has_selection() { + self.clear_vim_pending(); + return false; + } + let removed = if self.textarea.has_selection() { + self.textarea.delete_selection() + } else { + self.textarea + .start_selection(VisualSelectionMode::Character); + self.textarea.move_cursor_right(); + self.textarea.delete_selection() + }; + if let Some(text) = removed { + if !text.is_empty() { + self.set_vim_clipboard(text); + } + self.after_vim_text_edit(); + true + } else { + self.textarea.clear_selection(); + false + } + } + + fn delete_current_line(&mut self) -> bool { + let start = self.textarea.current_line_start(); + let mut end = self.textarea.current_line_end(); + if end < self.textarea.text().len() { + end += 1; + } + if start >= end { + return false; + } + let removed = self.textarea.text()[start..end].to_string(); + self.textarea.replace_range(start..end, ""); + self.textarea + .set_cursor(start.min(self.textarea.text().len())); + if !removed.is_empty() { + self.set_vim_clipboard(removed); + } + self.after_vim_text_edit(); + true + } + + fn apply_vim_delete_motion(&mut self, motion: VimDeleteMotion) -> bool { + let target = match motion { + VimDeleteMotion::ToNextWord => self.textarea.end_of_next_word(), + VimDeleteMotion::ToLineStart => self.textarea.current_line_start(), + VimDeleteMotion::ToLineEnd => self.textarea.current_line_end(), + }; + self.delete_with_selection(target) + } + + fn delete_with_selection(&mut self, target: usize) -> bool { + let original_cursor = self.textarea.cursor(); + if original_cursor == target { + return false; + } + + self.textarea + .start_selection(VisualSelectionMode::Character); + self.textarea.set_cursor(target); + let removed = self.textarea.delete_selection(); + let new_cursor = original_cursor.min(target).min(self.textarea.text().len()); + self.textarea.set_cursor(new_cursor); + + if let Some(text) = removed { + if !text.is_empty() { + self.set_vim_clipboard(text); + } + self.after_vim_text_edit(); + true + } else { + false + } + } + + fn open_newline_below(&mut self) { + self.textarea.move_cursor_to_end_of_line(false); + self.textarea.insert_str("\n"); + self.after_vim_text_edit(); + self.enter_vim_insert_mode(); + } + + fn open_newline_above(&mut self) { + self.textarea.move_cursor_to_beginning_of_line(false); + let line_start = self.textarea.cursor(); + self.textarea.insert_str("\n"); + self.textarea.set_cursor(line_start); + self.after_vim_text_edit(); + self.enter_vim_insert_mode(); + } + + fn paste_from_clipboard(&mut self, replace_selection: bool) -> bool { + let clip = match self.vim_clipboard() { + Some(text) => text.to_string(), + None => return false, + }; + if clip.is_empty() && !replace_selection { + return false; + } + + let mut replaced = None; + if replace_selection && self.textarea.has_selection() { + replaced = self.textarea.delete_selection(); + } + + if !clip.is_empty() { + self.textarea.insert_str(&clip); + } + self.after_vim_text_edit(); + + if let Some(text) = replaced { + if !text.is_empty() { + self.set_vim_clipboard(text); + } + } + true + } + /// Override the footer hint items displayed beneath the composer. Passing /// `None` restores the default shortcut footer. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { @@ -346,6 +664,13 @@ impl ChatComposer { PasteBurst::recommended_flush_delay() } + pub(crate) fn allows_backtrack_escape(&self) -> bool { + match self.vim.as_ref().map(|v| v.mode) { + Some(VimMode::Normal) | None => true, + _ => false, + } + } + /// Integrate results from an asynchronous file search. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // Only apply if user is still editing a token starting with `query`. @@ -384,7 +709,13 @@ impl ChatComposer { let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), - ActivePopup::None => self.handle_key_event_without_popup(key_event), + ActivePopup::None => { + if let Some(result) = self.handle_vim_key_event(&key_event) { + result + } else { + self.handle_key_event_without_popup(key_event) + } + } }; // Update (or hide/show) popup after processing the key. @@ -398,6 +729,304 @@ impl ChatComposer { result } + fn handle_vim_key_event(&mut self, key_event: &KeyEvent) -> Option<(InputResult, bool)> { + if !self.vim_enabled() { + return None; + } + if key_event.kind != KeyEventKind::Press && key_event.kind != KeyEventKind::Repeat { + return Some((InputResult::None, false)); + } + let mode = self.current_vim_mode()?; + match mode { + VimMode::Insert => self.handle_vim_insert_mode(key_event), + VimMode::Normal => self.handle_vim_normal_mode(key_event), + VimMode::Visual => { + self.handle_vim_visual_mode(key_event, VisualSelectionMode::Character) + } + VimMode::VisualLine => { + self.handle_vim_visual_mode(key_event, VisualSelectionMode::Line) + } + } + } + + fn handle_vim_insert_mode(&mut self, key_event: &KeyEvent) -> Option<(InputResult, bool)> { + if key_event.code == KeyCode::Esc && key_event.modifiers.is_empty() { + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + None + } + + fn handle_vim_normal_mode(&mut self, key_event: &KeyEvent) -> Option<(InputResult, bool)> { + if let Some(VimPending::Delete) = self.vim_pending() { + if key_event.modifiers.is_empty() && matches!(key_event.code, KeyCode::Char('d')) { + self.clear_vim_pending(); + let handled = self.delete_current_line(); + return Some((InputResult::None, handled)); + } + return self.handle_vim_delete_pending(key_event); + } + + if key_event.modifiers.contains(KeyModifiers::ALT) { + self.clear_vim_pending(); + return Some((InputResult::None, true)); + } + + if key_event.modifiers.contains(KeyModifiers::CONTROL) { + self.clear_vim_pending(); + return Some((InputResult::None, true)); + } + + match key_event.code { + KeyCode::Esc => { + self.clear_vim_pending(); + return None; + } + KeyCode::Char('h') => { + self.textarea.move_cursor_left(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('j') => { + self.textarea.move_cursor_down(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('k') => { + self.textarea.move_cursor_up(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('l') => { + self.textarea.move_cursor_right(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('w') => { + let pos = self.textarea.end_of_next_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('b') => { + let pos = self.textarea.beginning_of_previous_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('0') => { + self.textarea.move_cursor_to_beginning_of_line(false); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('$') => { + self.textarea.move_cursor_to_end_of_line(false); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('g') => { + if matches!(self.take_vim_pending(), Some(VimPending::G)) { + self.textarea.set_cursor(0); + self.after_vim_cursor_motion(); + } else { + self.set_vim_pending(VimPending::G); + } + return Some((InputResult::None, true)); + } + KeyCode::Char('d') => { + self.set_vim_pending(VimPending::Delete); + return Some((InputResult::None, true)); + } + KeyCode::Char('G') => { + self.textarea.set_cursor(self.textarea.text().len()); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('i') => { + self.enter_vim_insert_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('a') => { + self.textarea.move_cursor_right(); + self.enter_vim_insert_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('A') => { + self.textarea.move_cursor_to_end_of_line(false); + self.enter_vim_insert_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('I') => { + self.textarea.move_cursor_to_beginning_of_line(false); + self.enter_vim_insert_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('o') => { + self.open_newline_below(); + return Some((InputResult::None, true)); + } + KeyCode::Char('O') => { + self.open_newline_above(); + return Some((InputResult::None, true)); + } + KeyCode::Char('x') => { + if self.delete_char_under_cursor() { + return Some((InputResult::None, true)); + } + return Some((InputResult::None, true)); + } + KeyCode::Char('v') => { + self.enter_visual_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('V') => { + self.enter_visual_line_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('p') => { + self.paste_from_clipboard(false); + return Some((InputResult::None, true)); + } + KeyCode::Char('e') => { + let pos = self.textarea.end_of_next_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + _ => {} + } + + self.clear_vim_pending(); + Some((InputResult::None, true)) + } + + fn handle_vim_delete_pending(&mut self, key_event: &KeyEvent) -> Option<(InputResult, bool)> { + let motion = match key_event.code { + KeyCode::Char('w') => Some(VimDeleteMotion::ToNextWord), + KeyCode::Char('0') => Some(VimDeleteMotion::ToLineStart), + KeyCode::Char('$') => Some(VimDeleteMotion::ToLineEnd), + _ => None, + }; + + let handled = motion + .map(|m| self.apply_vim_delete_motion(m)) + .unwrap_or(false); + self.clear_vim_pending(); + handled + .then_some((InputResult::None, true)) + .or(Some((InputResult::None, true))) + } + + fn handle_vim_visual_mode( + &mut self, + key_event: &KeyEvent, + mode: VisualSelectionMode, + ) -> Option<(InputResult, bool)> { + self.textarea.update_selection_mode(mode); + + if key_event.modifiers.contains(KeyModifiers::ALT) { + self.clear_vim_pending(); + return Some((InputResult::None, true)); + } + + if key_event.modifiers.contains(KeyModifiers::CONTROL) { + self.clear_vim_pending(); + return Some((InputResult::None, true)); + } + + match key_event.code { + KeyCode::Esc => { + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('v') => { + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('V') => { + if mode == VisualSelectionMode::Line { + self.enter_vim_normal_mode(); + } else { + self.enter_visual_line_mode(); + } + return Some((InputResult::None, true)); + } + KeyCode::Char('h') => { + self.textarea.move_cursor_left(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('j') => { + self.textarea.move_cursor_down(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('k') => { + self.textarea.move_cursor_up(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('l') => { + self.textarea.move_cursor_right(); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('0') => { + self.textarea.move_cursor_to_beginning_of_line(false); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('$') => { + self.textarea.move_cursor_to_end_of_line(false); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('g') => { + if matches!(self.take_vim_pending(), Some(VimPending::G)) { + self.textarea.set_cursor(0); + self.after_vim_cursor_motion(); + } else { + self.set_vim_pending(VimPending::G); + } + return Some((InputResult::None, true)); + } + KeyCode::Char('G') => { + self.textarea.set_cursor(self.textarea.text().len()); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('d') => { + if let Some(removed) = self.textarea.delete_selection() { + if !removed.is_empty() { + self.set_vim_clipboard(removed); + } + self.after_vim_text_edit(); + } + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('y') => { + if let Some(range) = self.textarea.selection_range() { + let text = self.textarea.text()[range].to_string(); + if !text.is_empty() { + self.set_vim_clipboard(text); + } + } + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + KeyCode::Char('p') => { + self.paste_from_clipboard(true); + self.enter_vim_normal_mode(); + return Some((InputResult::None, true)); + } + _ => {} + } + + self.clear_vim_pending(); + Some((InputResult::None, true)) + } + /// Return true if either the slash-command popup or the file-search popup is active. pub(crate) fn popup_active(&self) -> bool { !matches!(self.active_popup, ActivePopup::None) @@ -1112,7 +1741,6 @@ impl ChatComposer { // Normal input handling self.textarea.input(input); - let text_after = self.textarea.text(); // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. let crossterm::event::KeyEvent { @@ -1134,33 +1762,7 @@ impl ChatComposer { self.paste_burst.clear_window_after_non_char(); } } - - // Check if any placeholders were removed and remove their corresponding pending pastes - self.pending_pastes - .retain(|(placeholder, _)| text_after.contains(placeholder)); - - // Keep attached images in proportion to how many matching placeholders exist in the text. - // This handles duplicate placeholders that share the same visible label. - if !self.attached_images.is_empty() { - let mut needed: HashMap = HashMap::new(); - for img in &self.attached_images { - needed - .entry(img.placeholder.clone()) - .or_insert_with(|| text_after.matches(&img.placeholder).count()); - } - - let mut used: HashMap = HashMap::new(); - let mut kept: Vec = Vec::with_capacity(self.attached_images.len()); - for img in self.attached_images.drain(..) { - let total_needed = *needed.get(&img.placeholder).unwrap_or(&0); - let used_count = used.entry(img.placeholder.clone()).or_insert(0); - if *used_count < total_needed { - kept.push(img); - *used_count += 1; - } - } - self.attached_images = kept; - } + self.reconcile_placeholders_and_attachments(); (InputResult::None, true) } @@ -1332,12 +1934,23 @@ impl ChatComposer { } fn footer_props(&self) -> FooterProps { + let vim_status = if self.vim_enabled() { + self.vim.as_ref().map(|state| match state.mode { + VimMode::Insert => VimStatus::Insert, + VimMode::Normal => VimStatus::Normal, + VimMode::Visual => VimStatus::Visual, + VimMode::VisualLine => VimStatus::VisualLine, + }) + } else { + None + }; FooterProps { mode: self.footer_mode(), esc_backtrack_hint: self.esc_backtrack_hint, use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, + vim_status, } } @@ -1347,7 +1960,9 @@ impl ChatComposer { FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, - FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty, + FooterMode::ShortcutPrompt if !self.is_empty() && !self.vim_enabled() => { + FooterMode::Empty + } other => other, } } @@ -1554,6 +2169,40 @@ impl WidgetRef for ChatComposer { } } +#[cfg(test)] +mod vim_tests { + use super::*; + use crate::app_event::AppEvent; + use tokio::sync::mpsc::unbounded_channel; + + fn vim_composer() -> ChatComposer { + let (tx_raw, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx_raw); + ChatComposer::new_with_keybinding_mode( + true, + sender, + false, + "".to_string(), + false, + KeybindingMode::Vim, + ) + } + + #[test] + fn esc_transitions_to_normal_mode() { + let mut composer = vim_composer(); + composer.insert_str("hello"); + assert!(!composer.allows_backtrack_escape()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(composer.allows_backtrack_escape()); + + let len = composer.textarea.text().len(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), len.saturating_sub(1)); + } +} + fn prompt_selection_action( prompt: &CustomPrompt, first_line: &str, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index b4c5617ddf..6f7fd39611 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -11,6 +11,14 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum VimStatus { + Insert, + Normal, + Visual, + VisualLine, +} + #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, @@ -18,6 +26,7 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, + pub(crate) vim_status: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -63,12 +72,35 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 { } pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + let lines = footer_lines(props); Paragraph::new(prefix_lines( - footer_lines(props), + lines.clone(), " ".repeat(FOOTER_INDENT_COLS).into(), " ".repeat(FOOTER_INDENT_COLS).into(), )) .render(area, buf); + + if let Some(status) = props.vim_status { + if matches!(props.mode, FooterMode::ShortcutPrompt) { + let label = match status { + VimStatus::Insert => "-- INSERT --", + VimStatus::Normal => "-- NORMAL --", + VimStatus::Visual => "-- VISUAL --", + VimStatus::VisualLine => "-- VISUAL LINE --", + }; + let span = Span::from(label.to_string()).dim(); + let label_width = label.len() as u16; + let available_width = area.width; + if available_width > label_width + 1 { + let mut x = area.x + available_width - label_width - 1; + let min_x = area.x + FOOTER_INDENT_COLS as u16 + 1; + if x < min_x { + x = min_x; + } + buf.set_span(x, area.y, &span, label_width); + } + } + } } fn footer_lines(props: FooterProps) -> Vec> { @@ -407,6 +439,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + vim_status: None, }, ); @@ -418,6 +451,7 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, context_window_percent: None, + vim_status: None, }, ); @@ -429,6 +463,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + vim_status: None, }, ); @@ -440,6 +475,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: None, + vim_status: None, }, ); @@ -451,6 +487,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + vim_status: None, }, ); @@ -462,6 +499,7 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + vim_status: None, }, ); @@ -473,6 +511,19 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: Some(72), + vim_status: None, + }, + ); + + snapshot_footer( + "footer_shortcuts_vim_normal", + FooterProps { + mode: FooterMode::ShortcutPrompt, + esc_backtrack_hint: false, + use_shift_enter_hint: false, + is_task_running: false, + context_window_percent: None, + vim_status: Some(VimStatus::Normal), }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6fe673a23b..b2e1d94aed 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::config_types::KeybindingMode; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -78,20 +79,33 @@ pub(crate) struct BottomPaneParams { pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, + pub(crate) keybinding_mode: KeybindingMode, } impl BottomPane { const BOTTOM_PAD_LINES: u16 = 1; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; - Self { - composer: ChatComposer::new( + let composer = if params.keybinding_mode == KeybindingMode::Vim { + ChatComposer::new_with_keybinding_mode( params.has_input_focus, params.app_event_tx.clone(), enhanced_keys_supported, params.placeholder_text, params.disable_paste_burst, - ), + params.keybinding_mode, + ) + } else { + ChatComposer::new( + params.has_input_focus, + params.app_event_tx.clone(), + enhanced_keys_supported, + params.placeholder_text, + params.disable_paste_burst, + ) + }; + Self { + composer, view_stack: Vec::new(), app_event_tx: params.app_event_tx, frame_requester: params.frame_requester, @@ -197,11 +211,14 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. + // If a task is running and the status line is visible, allow Esc to + // send an interrupt while the composer has focus — but only when + // Esc would be considered a non-editing key in the composer. In Vim + // mode this means only from NORMAL mode; in Emacs mode it's always allowed. if matches!(key_event.code, crossterm::event::KeyCode::Esc) && self.is_task_running && let Some(status) = &self.status + && self.composer.allows_backtrack_escape() { // Send Op::Interrupt status.interrupt(); @@ -393,7 +410,10 @@ impl BottomPane { /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { - !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() + !self.is_task_running + && self.view_stack.is_empty() + && !self.composer.popup_active() + && self.composer.allows_backtrack_escape() } pub(crate) fn show_view(&mut self, view: Box) { @@ -545,6 +565,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -565,6 +586,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); // Create an approval modal (active view). @@ -596,6 +618,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); // Start a running task so the status indicator is active above the composer. @@ -664,6 +687,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); // Begin a task: show initial status. @@ -695,6 +719,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); // Activate spinner (status view replaces composer) with no live ring. @@ -746,6 +771,7 @@ mod tests { enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 269fa345e0..596af177b0 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -5,6 +5,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Style; +use ratatui::style::Stylize; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; use std::cell::Ref; @@ -19,6 +20,18 @@ struct TextElement { range: Range, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VisualSelectionMode { + Character, + Line, +} + +#[derive(Debug, Clone)] +struct Selection { + anchor: usize, + mode: VisualSelectionMode, +} + #[derive(Debug)] pub(crate) struct TextArea { text: String, @@ -26,6 +39,7 @@ pub(crate) struct TextArea { wrap_cache: RefCell>, preferred_col: Option, elements: Vec, + selection: Option, } #[derive(Debug, Clone)] @@ -48,6 +62,7 @@ impl TextArea { wrap_cache: RefCell::new(None), preferred_col: None, elements: Vec::new(), + selection: None, } } @@ -57,6 +72,7 @@ impl TextArea { self.wrap_cache.replace(None); self.preferred_col = None; self.elements.clear(); + self.selection = None; } pub fn text(&self) -> &str { @@ -67,6 +83,74 @@ impl TextArea { self.insert_str_at(self.cursor_pos, text); } + pub(crate) fn has_selection(&self) -> bool { + self.selection.is_some() + } + + pub(crate) fn start_selection(&mut self, mode: VisualSelectionMode) { + let anchor = self.cursor_pos; + self.selection = Some(Selection { anchor, mode }); + } + + pub(crate) fn update_selection_mode(&mut self, mode: VisualSelectionMode) { + if let Some(selection) = self.selection.as_mut() { + selection.mode = mode; + } else { + self.start_selection(mode); + } + } + + pub(crate) fn clear_selection(&mut self) { + self.selection = None; + } + + pub(crate) fn selection_range(&self) -> Option> { + let selection = self.selection.as_ref()?; + match selection.mode { + VisualSelectionMode::Character => { + self.selection_range_character(selection.anchor, self.cursor_pos) + } + VisualSelectionMode::Line => { + Some(self.selection_range_line(selection.anchor, self.cursor_pos)) + } + } + } + + pub(crate) fn delete_selection(&mut self) -> Option { + let range = self.selection_range()?; + let removed = self.text[range.clone()].to_string(); + self.replace_range_raw(range, ""); + self.selection = None; + Some(removed) + } + + fn selection_range_character(&self, anchor: usize, cursor: usize) -> Option> { + if self.text.is_empty() { + return None; + } + let mut start = anchor.min(cursor).min(self.text.len()); + let mut end = anchor.max(cursor).min(self.text.len()); + if start == end { + end = self.next_atomic_boundary(end); + if start == end { + start = self.prev_atomic_boundary(start); + } + } else { + end = self.next_atomic_boundary(end); + } + start = start.min(self.text.len()); + end = end.min(self.text.len()); + if start >= end { None } else { Some(start..end) } + } + + fn selection_range_line(&self, anchor: usize, cursor: usize) -> Range { + let anchor_range = self.line_range_inclusive(anchor); + let cursor_range = self.line_range_inclusive(cursor); + let start = anchor_range.start.min(cursor_range.start); + let end = anchor_range.end.max(cursor_range.end); + start..end + } + pub fn insert_str_at(&mut self, pos: usize, text: &str) { let pos = self.clamp_pos_for_insertion(pos); self.text.insert_str(pos, text); @@ -114,6 +198,7 @@ impl TextArea { // Ensure cursor is not inside an element self.cursor_pos = self.clamp_pos_to_nearest_boundary(self.cursor_pos); + self.selection = None; } pub fn cursor(&self) -> usize { @@ -202,6 +287,27 @@ impl TextArea { self.end_of_line(self.cursor_pos) } + pub(crate) fn current_line_start(&self) -> usize { + self.beginning_of_current_line() + } + + pub(crate) fn current_line_end(&self) -> usize { + self.end_of_current_line() + } + + fn line_range_inclusive(&self, pos: usize) -> Range { + if self.text.is_empty() { + return 0..0; + } + let clamped = pos.min(self.text.len()); + let start = self.beginning_of_line(clamped); + let mut end = self.end_of_line(clamped); + if end < self.text.len() { + end += 1; + } + start..end + } + pub fn input(&mut self, event: KeyEvent) { match event { // Some terminals (or configurations) send Control key chords as @@ -922,6 +1028,7 @@ impl TextArea { lines: &[Range], range: std::ops::Range, ) { + let selection = self.selection_range(); for (row, idx) in range.enumerate() { let r = &lines[idx]; let y = area.y + row as u16; @@ -942,6 +1049,21 @@ impl TextArea { let style = Style::default().fg(Color::Cyan); buf.set_string(area.x + x_off, y, styled, style); } + + if let Some(selection_range) = &selection { + let overlap_start = selection_range.start.max(line_range.start); + let overlap_end = selection_range.end.min(line_range.end); + if overlap_start < overlap_end { + let x_off = self.text[line_range.start..overlap_start].width() as u16; + let selection_str = &self.text[overlap_start..overlap_end]; + buf.set_string( + area.x + x_off, + y, + selection_str, + Style::default().reversed(), + ); + } + } } } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..1ff8570e74 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -910,6 +910,7 @@ impl ChatWidget { enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, + keybinding_mode: config.keybinding_mode, }), active_cell: None, config: config.clone(), @@ -973,6 +974,7 @@ impl ChatWidget { enhanced_keys_supported, placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, + keybinding_mode: config.keybinding_mode, }), active_cell: None, config: config.clone(), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0ab31daa32..428be8615f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; use codex_core::config::OPENAI_DEFAULT_MODEL; +use codex_core::config_types::KeybindingMode; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -256,6 +257,7 @@ fn make_chatwidget_manual() -> ( enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, + keybinding_mode: KeybindingMode::Emacs, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let widget = ChatWidget { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 7997625aae..fc9601027a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -8,8 +8,10 @@ use crate::key_hint::KeyBinding; use crate::render::renderable::Renderable; use crate::tui; use crate::tui::TuiEvent; +use codex_core::config_types::KeybindingMode; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; @@ -29,8 +31,11 @@ pub(crate) enum Overlay { } impl Overlay { - pub(crate) fn new_transcript(cells: Vec>) -> Self { - Self::Transcript(TranscriptOverlay::new(cells)) + pub(crate) fn new_transcript( + cells: Vec>, + keybinding_mode: KeybindingMode, + ) -> Self { + Self::Transcript(TranscriptOverlay::new(cells, keybinding_mode)) } pub(crate) fn new_static_with_lines(lines: Vec>, title: String) -> Self { @@ -71,14 +76,32 @@ const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc); const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter); const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t')); const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c')); +const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); +const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); +const KEY_G: KeyBinding = key_hint::plain(KeyCode::Char('g')); +const KEY_SHIFT_G: KeyBinding = key_hint::shift(KeyCode::Char('g')); +const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); +const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); +const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); +const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); +const KEY_CTRL_E: KeyBinding = key_hint::ctrl(KeyCode::Char('e')); +const KEY_CTRL_Y: KeyBinding = key_hint::ctrl(KeyCode::Char('y')); // Common pager navigation hints rendered on the first line -const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ +const PAGER_KEY_HINTS_EMACS: &[(&[KeyBinding], &str)] = &[ (&[KEY_UP, KEY_DOWN], "to scroll"), (&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"), (&[KEY_HOME, KEY_END], "to jump"), ]; +const PAGER_KEY_HINTS_VIM: &[(&[KeyBinding], &str)] = &[ + (&[KEY_J, KEY_K], "to scroll"), + (&[KEY_CTRL_F, KEY_CTRL_B], "to page"), + (&[KEY_CTRL_D, KEY_CTRL_U], "half page"), + (&[KEY_G, KEY_G], "to top"), + (&[KEY_SHIFT_G], "to bottom"), +]; + // Render a single line of key hints from (key(s), description) pairs. fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) { let mut spans: Vec> = vec![" ".into()]; @@ -109,10 +132,22 @@ struct PagerView { last_rendered_height: Option, /// If set, on next render ensure this chunk is visible. pending_scroll_chunk: Option, + keybinding_mode: KeybindingMode, + vim_prefix: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum VimPrefix { + G, } impl PagerView { - fn new(renderables: Vec>, title: String, scroll_offset: usize) -> Self { + fn new( + renderables: Vec>, + title: String, + scroll_offset: usize, + keybinding_mode: KeybindingMode, + ) -> Self { Self { renderables, scroll_offset, @@ -120,9 +155,43 @@ impl PagerView { last_content_height: None, last_rendered_height: None, pending_scroll_chunk: None, + keybinding_mode, + vim_prefix: None, + } + } + + fn keybinding_mode(&self) -> KeybindingMode { + self.keybinding_mode + } + + fn scroll_lines(&mut self, delta: isize) { + if delta == 0 { + return; + } + if delta > 0 { + let magnitude = delta as usize; + self.scroll_offset = self.scroll_offset.saturating_add(magnitude); + } else { + let magnitude = (-delta) as usize; + self.scroll_offset = self.scroll_offset.saturating_sub(magnitude); } } + fn request_redraw(tui: &mut tui::Tui) { + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + } + + fn page_step(area: Rect) -> usize { + usize::from(area.height.max(1)) + } + + fn half_page_step(area: Rect) -> usize { + let height = area.height.max(1); + let half = std::cmp::max(1, height / 2); + usize::from(half) + } + fn content_height(&self, width: u16) -> usize { self.renderables .iter() @@ -227,7 +296,104 @@ impl PagerView { .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); } + fn handle_vim_key(&mut self, tui: &mut tui::Tui, key_event: &KeyEvent) -> bool { + let ctrl = key_event.modifiers.contains(KeyModifiers::CONTROL); + let alt = key_event.modifiers.contains(KeyModifiers::ALT); + + if ctrl { + let mut handled = false; + if let KeyCode::Char(ch) = key_event.code { + let lower = ch.to_ascii_lowercase(); + let area = self.content_area(tui.terminal.viewport_area); + handled = match lower { + 'f' => { + self.scroll_lines(Self::page_step(area) as isize); + true + } + 'b' => { + self.scroll_lines(-(Self::page_step(area) as isize)); + true + } + 'd' => { + self.scroll_lines(Self::half_page_step(area) as isize); + true + } + 'u' => { + self.scroll_lines(-(Self::half_page_step(area) as isize)); + true + } + 'e' => { + self.scroll_lines(1); + true + } + 'y' => { + self.scroll_lines(-1); + true + } + _ => false, + }; + } + self.vim_prefix = None; + if handled { + Self::request_redraw(tui); + } + return handled; + } + + if alt { + self.vim_prefix = None; + return false; + } + + if self.vim_prefix.take().is_some() { + if let KeyCode::Char(ch) = key_event.code { + if ch.to_ascii_lowercase() == 'g' { + self.scroll_offset = 0; + Self::request_redraw(tui); + return true; + } + } + } + + let mut handled = false; + match key_event.code { + KeyCode::Char(ch) => { + let lower = ch.to_ascii_lowercase(); + let shift = key_event.modifiers.contains(KeyModifiers::SHIFT); + handled = match lower { + 'j' => { + self.scroll_lines(1); + true + } + 'k' => { + self.scroll_lines(-1); + true + } + 'g' if shift => { + self.scroll_offset = usize::MAX; + true + } + 'g' => { + self.vim_prefix = Some(VimPrefix::G); + true + } + _ => false, + }; + } + _ => {} + } + + if handled && !matches!(self.vim_prefix, Some(VimPrefix::G)) { + Self::request_redraw(tui); + } + + handled + } + fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { + if self.keybinding_mode == KeybindingMode::Vim && self.handle_vim_key(tui, &key_event) { + return Ok(()); + } match key_event { e if KEY_UP.is_press(e) => { self.scroll_offset = self.scroll_offset.saturating_sub(1); @@ -253,8 +419,7 @@ impl PagerView { return Ok(()); } } - tui.frame_requester() - .schedule_frame_in(Duration::from_millis(16)); + Self::request_redraw(tui); Ok(()) } @@ -355,12 +520,16 @@ pub(crate) struct TranscriptOverlay { } impl TranscriptOverlay { - pub(crate) fn new(transcript_cells: Vec>) -> Self { + pub(crate) fn new( + transcript_cells: Vec>, + keybinding_mode: KeybindingMode, + ) -> Self { Self { view: PagerView::new( Self::render_cells_to_texts(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, + keybinding_mode, ), cells: transcript_cells, highlight_cell: None, @@ -424,13 +593,21 @@ impl TranscriptOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); + let top_hints = if self.view.keybinding_mode() == KeybindingMode::Vim { + PAGER_KEY_HINTS_VIM + } else { + PAGER_KEY_HINTS_EMACS + }; + render_key_hints(line1, buf, top_hints); let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")]; if self.highlight_cell.is_some() { pairs.push((&[KEY_ENTER], "to edit message")); } + if self.view.keybinding_mode() == KeybindingMode::Vim { + pairs.push((&[KEY_CTRL_E, KEY_CTRL_Y], "line scroll")); + } render_key_hints(line2, buf, &pairs); } @@ -484,7 +661,7 @@ impl StaticOverlay { pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { Self { - view: PagerView::new(renderables, title, 0), + view: PagerView::new(renderables, title, 0, KeybindingMode::Emacs), is_done: false, } } @@ -492,7 +669,7 @@ impl StaticOverlay { fn render_hints(&self, area: Rect, buf: &mut Buffer) { let line1 = Rect::new(area.x, area.y, area.width, 1); let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); - render_key_hints(line1, buf, PAGER_KEY_HINTS); + render_key_hints(line1, buf, PAGER_KEY_HINTS_EMACS); let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")]; render_key_hints(line2, buf, &pairs); } @@ -601,9 +778,12 @@ mod tests { #[test] fn edit_prev_hint_is_visible() { - let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { - lines: vec![Line::from("hello")], - })]); + let mut overlay = TranscriptOverlay::new( + vec![Arc::new(TestCell { + lines: vec![Line::from("hello")], + })], + KeybindingMode::Emacs, + ); // Render into a small buffer and assert the backtrack hint is present let area = Rect::new(0, 0, 40, 10); @@ -627,17 +807,20 @@ mod tests { #[test] fn transcript_overlay_snapshot_basic() { // Prepare a transcript overlay with a few lines - let mut overlay = TranscriptOverlay::new(vec![ - Arc::new(TestCell { - lines: vec![Line::from("alpha")], - }), - Arc::new(TestCell { - lines: vec![Line::from("beta")], - }), - Arc::new(TestCell { - lines: vec![Line::from("gamma")], - }), - ]); + let mut overlay = TranscriptOverlay::new( + vec![ + Arc::new(TestCell { + lines: vec![Line::from("alpha")], + }), + Arc::new(TestCell { + lines: vec![Line::from("beta")], + }), + Arc::new(TestCell { + lines: vec![Line::from("gamma")], + }), + ], + KeybindingMode::Emacs, + ); let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); @@ -714,7 +897,7 @@ mod tests { let exec_cell: Arc = Arc::new(exec_cell); cells.push(exec_cell); - let mut overlay = TranscriptOverlay::new(cells); + let mut overlay = TranscriptOverlay::new(cells, KeybindingMode::Emacs); let area = Rect::new(0, 0, 80, 12); let mut buf = Buffer::empty(area); @@ -736,6 +919,7 @@ mod tests { }) as Arc }) .collect(), + KeybindingMode::Emacs, ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -763,6 +947,7 @@ mod tests { }) as Arc }) .collect(), + KeybindingMode::Emacs, ); let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) @@ -796,6 +981,7 @@ mod tests { vec![paragraph_block("a", 2), paragraph_block("b", 3)], "T".to_string(), 0, + KeybindingMode::Emacs, ); assert_eq!(pv.content_height(80), 5); @@ -811,6 +997,7 @@ mod tests { ], "T".to_string(), 0, + KeybindingMode::Emacs, ); let area = Rect::new(0, 0, 20, 8); @@ -846,6 +1033,7 @@ mod tests { ], "T".to_string(), 0, + KeybindingMode::Emacs, ); let area = Rect::new(0, 0, 20, 3); @@ -857,7 +1045,12 @@ mod tests { #[test] fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() { - let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0); + let mut pv = PagerView::new( + vec![paragraph_block("a", 10)], + "T".to_string(), + 0, + KeybindingMode::Emacs, + ); let area = Rect::new(0, 0, 20, 8); let mut buf = Buffer::empty(area); diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 457e37fe06..88ce2d0b2e 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -14,6 +14,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; +use codex_core::config_types::KeybindingMode; /// Action returned from feeding a key event into the ComposerInput. pub enum ComposerAction { @@ -37,7 +38,14 @@ impl ComposerInput { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let sender = AppEventSender::new(tx.clone()); // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. - let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); + let inner = ChatComposer::new_with_keybinding_mode( + true, + sender, + true, + "Compose new task".to_string(), + false, + KeybindingMode::Emacs, + ); Self { inner, _tx: tx, rx } } From eadd9afd46036f6716be5e3ff854307164a359dc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 12 Oct 2025 01:49:52 +0530 Subject: [PATCH 2/7] tui: add Vim word motions and visual-mode tests to chat composer Implement 'w', 'b', and 'e' word motions in Vim mode and add is_vim_normal_mode() helper. Add unit tests covering visual-mode word motions and normal-mode detection. Rationale: improve Vim editing experience in the composer. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index da39567534..ccd8a0b2b5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -396,6 +396,10 @@ impl ChatComposer { self.keybinding_mode == KeybindingMode::Vim } + pub(crate) fn is_vim_normal_mode(&self) -> bool { + matches!(self.vim.as_ref().map(|v| v.mode), Some(VimMode::Normal)) + } + fn current_vim_mode(&self) -> Option { self.vim.as_ref().map(|v| v.mode) } @@ -814,6 +818,12 @@ impl ChatComposer { self.after_vim_cursor_motion(); return Some((InputResult::None, true)); } + KeyCode::Char('e') => { + let pos = self.textarea.end_of_next_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } KeyCode::Char('0') => { self.textarea.move_cursor_to_beginning_of_line(false); self.after_vim_cursor_motion(); @@ -887,12 +897,6 @@ impl ChatComposer { self.paste_from_clipboard(false); return Some((InputResult::None, true)); } - KeyCode::Char('e') => { - let pos = self.textarea.end_of_next_word(); - self.textarea.set_cursor(pos); - self.after_vim_cursor_motion(); - return Some((InputResult::None, true)); - } _ => {} } @@ -971,6 +975,24 @@ impl ChatComposer { self.after_vim_cursor_motion(); return Some((InputResult::None, true)); } + KeyCode::Char('w') => { + let pos = self.textarea.end_of_next_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('b') => { + let pos = self.textarea.beginning_of_previous_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } + KeyCode::Char('e') => { + let pos = self.textarea.end_of_next_word(); + self.textarea.set_cursor(pos); + self.after_vim_cursor_motion(); + return Some((InputResult::None, true)); + } KeyCode::Char('0') => { self.textarea.move_cursor_to_beginning_of_line(false); self.after_vim_cursor_motion(); @@ -2201,6 +2223,42 @@ mod vim_tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); assert_eq!(composer.textarea.cursor(), len.saturating_sub(1)); } + + #[test] + fn visual_mode_word_motions() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar baz".to_string()); + composer.textarea.set_cursor(0); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 3); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 7); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 4); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 7); + + assert!(composer.textarea.has_selection()); + } + + #[test] + fn detects_vim_normal_mode() { + let mut composer = vim_composer(); + assert!(!composer.is_vim_normal_mode()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert!(!composer.is_vim_normal_mode()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(composer.is_vim_normal_mode()); + } } fn prompt_selection_action( From 7e07d97cb6324f4eade8a217e2ab1afaee92170e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 12 Oct 2025 01:49:52 +0530 Subject: [PATCH 3/7] tui: expose composer_in_vim_normal_mode() on bottom pane Add composer_in_vim_normal_mode() that delegates to the composer. Enables higher-level UI to gate behavior based on Vim normal mode. --- codex-rs/tui/src/bottom_pane/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b2e1d94aed..38e9b31dff 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -406,6 +406,10 @@ impl BottomPane { self.is_task_running } + pub(crate) fn composer_in_vim_normal_mode(&self) -> bool { + self.composer.is_vim_normal_mode() + } + /// Return true when the pane is in the regular composer state without any /// overlays or popups and not running a task. This is the safe context to /// use Esc-Esc for backtracking from the main view. From 271ef95756b385f4ce5a7749d839d4cf9701aa1d Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 12 Oct 2025 01:49:52 +0530 Subject: [PATCH 4/7] tui: expose composer_in_vim_normal_mode() on chat widget Delegate composer_in_vim_normal_mode() through ChatWidget so top-level UI can check composer Vim state. --- codex-rs/tui/src/chatwidget.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1ff8570e74..d88c2b5ba0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1875,6 +1875,10 @@ impl ChatWidget { self.bottom_pane.composer_is_empty() } + pub(crate) fn composer_in_vim_normal_mode(&self) -> bool { + self.bottom_pane.composer_in_vim_normal_mode() + } + /// True when the UI is in the regular composer state with no running task, /// no modal overlay (e.g. approvals or status indicator), and no composer popups. /// In this state Esc-Esc backtracking is enabled. From 0c635de99bca3ca0cbe95ad7cc8a7f29fca07fb4 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 12 Oct 2025 01:49:52 +0530 Subject: [PATCH 5/7] tui: open transcript with 't' in Vim normal mode Wire the 't' key to open the transcript overlay when the composer is in Vim normal mode. Improves discoverability of transcript while using Vim bindings. --- codex-rs/tui/src/app.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 30a70c9423..fd54376496 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -17,6 +17,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; +use codex_core::config_types::KeybindingMode; use codex_core::model_family::find_family_for_model; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; @@ -429,6 +430,21 @@ impl App { )); tui.frame_requester().schedule_frame(); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if self.config.keybinding_mode == KeybindingMode::Vim + && self.chat_widget.composer_in_vim_normal_mode() => + { + let _ = tui.enter_alt_screen(); + self.overlay = Some(Overlay::new_transcript( + self.transcript_cells.clone(), + self.config.keybinding_mode, + )); + tui.frame_requester().schedule_frame(); + } // Esc primes/advances backtracking only in normal (not working) mode // with an empty composer. In any other state, forward Esc so the // active UI (e.g. status indicator, modals, popups) handles it. From ba24439542b5b8e3bc046a0c1dc53e4a6605af94 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sun, 12 Oct 2025 01:49:52 +0530 Subject: [PATCH 6/7] tui: update pager key semantics (unify page for Ctrl-D/Ctrl-U, remove other handlers) Unify page semantics so Ctrl-D/Ctrl-U perform full-page scrolling. Remove Ctrl-F/Ctrl-B and Ctrl-E/Ctrl-Y handlers and hints. Update pager key hints accordingly. Rationale: simplify and align pager behaviour for Vim mode; note this may affect users relying on previous handlers. --- codex-rs/tui/src/pager_overlay.rs | 36 +++---------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index fc9601027a..823e1ccfc2 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -80,12 +80,8 @@ const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j')); const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k')); const KEY_G: KeyBinding = key_hint::plain(KeyCode::Char('g')); const KEY_SHIFT_G: KeyBinding = key_hint::shift(KeyCode::Char('g')); -const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f')); -const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b')); const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d')); const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u')); -const KEY_CTRL_E: KeyBinding = key_hint::ctrl(KeyCode::Char('e')); -const KEY_CTRL_Y: KeyBinding = key_hint::ctrl(KeyCode::Char('y')); // Common pager navigation hints rendered on the first line const PAGER_KEY_HINTS_EMACS: &[(&[KeyBinding], &str)] = &[ @@ -96,8 +92,7 @@ const PAGER_KEY_HINTS_EMACS: &[(&[KeyBinding], &str)] = &[ const PAGER_KEY_HINTS_VIM: &[(&[KeyBinding], &str)] = &[ (&[KEY_J, KEY_K], "to scroll"), - (&[KEY_CTRL_F, KEY_CTRL_B], "to page"), - (&[KEY_CTRL_D, KEY_CTRL_U], "half page"), + (&[KEY_CTRL_D, KEY_CTRL_U], "to page"), (&[KEY_G, KEY_G], "to top"), (&[KEY_SHIFT_G], "to bottom"), ]; @@ -186,12 +181,6 @@ impl PagerView { usize::from(area.height.max(1)) } - fn half_page_step(area: Rect) -> usize { - let height = area.height.max(1); - let half = std::cmp::max(1, height / 2); - usize::from(half) - } - fn content_height(&self, width: u16) -> usize { self.renderables .iter() @@ -306,28 +295,12 @@ impl PagerView { let lower = ch.to_ascii_lowercase(); let area = self.content_area(tui.terminal.viewport_area); handled = match lower { - 'f' => { - self.scroll_lines(Self::page_step(area) as isize); - true - } - 'b' => { - self.scroll_lines(-(Self::page_step(area) as isize)); - true - } 'd' => { - self.scroll_lines(Self::half_page_step(area) as isize); + self.scroll_lines(Self::page_step(area) as isize); true } 'u' => { - self.scroll_lines(-(Self::half_page_step(area) as isize)); - true - } - 'e' => { - self.scroll_lines(1); - true - } - 'y' => { - self.scroll_lines(-1); + self.scroll_lines(-(Self::page_step(area) as isize)); true } _ => false, @@ -605,9 +578,6 @@ impl TranscriptOverlay { if self.highlight_cell.is_some() { pairs.push((&[KEY_ENTER], "to edit message")); } - if self.view.keybinding_mode() == KeybindingMode::Vim { - pairs.push((&[KEY_CTRL_E, KEY_CTRL_Y], "line scroll")); - } render_key_hints(line2, buf, &pairs); } From 79e711dbfbdcddb0ddc87af6c82add1352944785 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Sat, 18 Oct 2025 19:15:59 +0530 Subject: [PATCH 7/7] resolving the comments of `dw` and `w` in normal mode --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 173 +++++++++++++++--- codex-rs/tui/src/bottom_pane/footer.rs | 36 ++-- codex-rs/tui/src/bottom_pane/textarea.rs | 136 +++++++++++++- codex-rs/tui/src/pager_overlay.rs | 62 +++---- 4 files changed, 323 insertions(+), 84 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ef27bac4be..a5b461c0dd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -141,7 +141,8 @@ enum VimPending { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum VimDeleteMotion { - ToNextWord, + ToNextWordStart, + ThroughNextWordEnd, ToLineStart, ToLineEnd, } @@ -545,12 +546,24 @@ impl ChatComposer { } fn apply_vim_delete_motion(&mut self, motion: VimDeleteMotion) -> bool { - let target = match motion { - VimDeleteMotion::ToNextWord => self.textarea.end_of_next_word(), - VimDeleteMotion::ToLineStart => self.textarea.current_line_start(), - VimDeleteMotion::ToLineEnd => self.textarea.current_line_end(), - }; - self.delete_with_selection(target) + match motion { + VimDeleteMotion::ToNextWordStart => { + let target = self.textarea.beginning_of_next_word(); + self.delete_forward_range(target) + } + VimDeleteMotion::ThroughNextWordEnd => { + let target = self.textarea.end_of_next_word(); + self.delete_forward_range(target) + } + VimDeleteMotion::ToLineStart => { + let target = self.textarea.current_line_start(); + self.delete_with_selection(target) + } + VimDeleteMotion::ToLineEnd => { + let target = self.textarea.current_line_end(); + self.delete_with_selection(target) + } + } } fn delete_with_selection(&mut self, target: usize) -> bool { @@ -577,6 +590,26 @@ impl ChatComposer { } } + fn delete_forward_range(&mut self, target: usize) -> bool { + let start = self.textarea.cursor(); + let end = target.min(self.textarea.text().len()); + if start >= end { + return false; + } + let removed = self.textarea.text()[start..end].to_string(); + if removed.is_empty() { + return false; + } + + self.textarea.clear_selection(); + self.textarea.replace_range(start..end, ""); + self.textarea + .set_cursor(start.min(self.textarea.text().len())); + self.set_vim_clipboard(removed); + self.after_vim_text_edit(); + true + } + fn open_newline_below(&mut self) { self.textarea.move_cursor_to_end_of_line(false); self.textarea.insert_str("\n"); @@ -612,10 +645,10 @@ impl ChatComposer { } self.after_vim_text_edit(); - if let Some(text) = replaced { - if !text.is_empty() { - self.set_vim_clipboard(text); - } + if let Some(text) = replaced + && !text.is_empty() + { + self.set_vim_clipboard(text); } true } @@ -680,10 +713,10 @@ impl ChatComposer { } pub(crate) fn allows_backtrack_escape(&self) -> bool { - match self.vim.as_ref().map(|v| v.mode) { - Some(VimMode::Normal) | None => true, - _ => false, - } + matches!( + self.vim.as_ref().map(|v| v.mode), + Some(VimMode::Normal) | None + ) } /// Integrate results from an asynchronous file search. @@ -818,7 +851,7 @@ impl ChatComposer { return Some((InputResult::None, true)); } KeyCode::Char('w') => { - let pos = self.textarea.end_of_next_word(); + let pos = self.textarea.beginning_of_next_word(); self.textarea.set_cursor(pos); self.after_vim_cursor_motion(); return Some((InputResult::None, true)); @@ -830,7 +863,7 @@ impl ChatComposer { return Some((InputResult::None, true)); } KeyCode::Char('e') => { - let pos = self.textarea.end_of_next_word(); + let pos = self.textarea.end_of_next_word_inclusive(); self.textarea.set_cursor(pos); self.after_vim_cursor_motion(); return Some((InputResult::None, true)); @@ -917,7 +950,8 @@ impl ChatComposer { fn handle_vim_delete_pending(&mut self, key_event: &KeyEvent) -> Option<(InputResult, bool)> { let motion = match key_event.code { - KeyCode::Char('w') => Some(VimDeleteMotion::ToNextWord), + KeyCode::Char('w') => Some(VimDeleteMotion::ToNextWordStart), + KeyCode::Char('e') => Some(VimDeleteMotion::ThroughNextWordEnd), KeyCode::Char('0') => Some(VimDeleteMotion::ToLineStart), KeyCode::Char('$') => Some(VimDeleteMotion::ToLineEnd), _ => None, @@ -938,6 +972,7 @@ impl ChatComposer { mode: VisualSelectionMode, ) -> Option<(InputResult, bool)> { self.textarea.update_selection_mode(mode); + self.textarea.set_selection_tail_inclusive(true); if key_event.modifiers.contains(KeyModifiers::ALT) { self.clear_vim_pending(); @@ -987,7 +1022,8 @@ impl ChatComposer { return Some((InputResult::None, true)); } KeyCode::Char('w') => { - let pos = self.textarea.end_of_next_word(); + self.textarea.set_selection_tail_inclusive(false); + let pos = self.textarea.beginning_of_next_word(); self.textarea.set_cursor(pos); self.after_vim_cursor_motion(); return Some((InputResult::None, true)); @@ -999,7 +1035,8 @@ impl ChatComposer { return Some((InputResult::None, true)); } KeyCode::Char('e') => { - let pos = self.textarea.end_of_next_word(); + self.textarea.set_selection_tail_inclusive(true); + let pos = self.textarea.end_of_next_word_inclusive(); self.textarea.set_cursor(pos); self.after_vim_cursor_motion(); return Some((InputResult::None, true)); @@ -2238,6 +2275,7 @@ impl WidgetRef for ChatComposer { mod vim_tests { use super::*; use crate::app_event::AppEvent; + use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; fn vim_composer() -> ChatComposer { @@ -2277,18 +2315,105 @@ mod vim_tests { composer.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 3); + assert_eq!(composer.textarea.cursor(), 4); composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 7); + assert_eq!(composer.textarea.cursor(), 8); composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); assert_eq!(composer.textarea.cursor(), 4); composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.cursor(), 7); - + assert_eq!(composer.textarea.cursor(), 6); assert!(composer.textarea.has_selection()); + let selection = composer.textarea.selection_range().unwrap(); + assert_eq!(selection, 0..7); + } + + #[test] + fn normal_mode_w_moves_to_word_start() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar baz".to_string()); + composer.textarea.set_cursor(0); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 4); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 8); + } + + #[test] + fn normal_mode_e_moves_to_word_end() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar".to_string()); + composer.textarea.set_cursor(0); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 2); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 6); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 6); + } + + #[test] + fn normal_mode_w_skips_consecutive_whitespace() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar".to_string()); + composer.textarea.set_cursor(1); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), 5); + } + + #[test] + fn normal_mode_w_at_end_does_not_move() { + let mut composer = vim_composer(); + composer.set_text_content("foo".to_string()); + let len = composer.textarea.text().len(); + composer.textarea.set_cursor(len); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.cursor(), len); + } + + #[test] + fn operator_pending_dw_and_de_match_vim() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar".to_string()); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "bar"); + + composer.set_text_content("foo bar".to_string()); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), " bar"); + } + + #[test] + fn visual_mode_w_excludes_next_word_start_from_selection() { + let mut composer = vim_composer(); + composer.set_text_content("foo bar".to_string()); + composer.textarea.set_cursor(0); + + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + + assert_eq!(composer.textarea.cursor(), 4); + let selection = composer.textarea.selection_range().unwrap(); + assert_eq!(selection, 0..4); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index a387357061..9fbf08709e 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -80,25 +80,25 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { )) .render(area, buf); - if let Some(status) = props.vim_status { - if matches!(props.mode, FooterMode::ShortcutSummary) { - let label = match status { - VimStatus::Insert => "-- INSERT --", - VimStatus::Normal => "-- NORMAL --", - VimStatus::Visual => "-- VISUAL --", - VimStatus::VisualLine => "-- VISUAL LINE --", - }; - let span = Span::from(label.to_string()).dim(); - let label_width = label.len() as u16; - let available_width = area.width; - if available_width > label_width + 1 { - let mut x = area.x + available_width - label_width - 1; - let min_x = area.x + FOOTER_INDENT_COLS as u16 + 1; - if x < min_x { - x = min_x; - } - buf.set_span(x, area.y, &span, label_width); + if let Some(status) = props.vim_status + && matches!(props.mode, FooterMode::ShortcutSummary) + { + let label = match status { + VimStatus::Insert => "-- INSERT --", + VimStatus::Normal => "-- NORMAL --", + VimStatus::Visual => "-- VISUAL --", + VimStatus::VisualLine => "-- VISUAL LINE --", + }; + let span = Span::from(label.to_string()).dim(); + let label_width = label.len() as u16; + let available_width = area.width; + if available_width > label_width + 1 { + let mut x = area.x + available_width - label_width - 1; + let min_x = area.x + FOOTER_INDENT_COLS as u16 + 1; + if x < min_x { + x = min_x; } + buf.set_span(x, area.y, &span, label_width); } } } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 9abbafa761..99f6f57a1f 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -30,6 +30,7 @@ pub(crate) enum VisualSelectionMode { struct Selection { anchor: usize, mode: VisualSelectionMode, + tail_inclusive: bool, } #[derive(Debug)] @@ -92,7 +93,11 @@ impl TextArea { pub(crate) fn start_selection(&mut self, mode: VisualSelectionMode) { let anchor = self.cursor_pos; - self.selection = Some(Selection { anchor, mode }); + self.selection = Some(Selection { + anchor, + mode, + tail_inclusive: true, + }); } pub(crate) fn update_selection_mode(&mut self, mode: VisualSelectionMode) { @@ -103,6 +108,12 @@ impl TextArea { } } + pub(crate) fn set_selection_tail_inclusive(&mut self, inclusive: bool) { + if let Some(selection) = self.selection.as_mut() { + selection.tail_inclusive = inclusive; + } + } + pub(crate) fn clear_selection(&mut self) { self.selection = None; } @@ -110,9 +121,11 @@ impl TextArea { pub(crate) fn selection_range(&self) -> Option> { let selection = self.selection.as_ref()?; match selection.mode { - VisualSelectionMode::Character => { - self.selection_range_character(selection.anchor, self.cursor_pos) - } + VisualSelectionMode::Character => self.selection_range_character( + selection.anchor, + self.cursor_pos, + selection.tail_inclusive, + ), VisualSelectionMode::Line => { Some(self.selection_range_line(selection.anchor, self.cursor_pos)) } @@ -127,18 +140,24 @@ impl TextArea { Some(removed) } - fn selection_range_character(&self, anchor: usize, cursor: usize) -> Option> { + fn selection_range_character( + &self, + anchor: usize, + cursor: usize, + tail_inclusive: bool, + ) -> Option> { if self.text.is_empty() { return None; } let mut start = anchor.min(cursor).min(self.text.len()); let mut end = anchor.max(cursor).min(self.text.len()); + let forward = anchor <= cursor; if start == end { end = self.next_atomic_boundary(end); if start == end { start = self.prev_atomic_boundary(start); } - } else { + } else if tail_inclusive || !forward { end = self.next_atomic_boundary(end); } start = start.min(self.text.len()); @@ -964,12 +983,63 @@ impl TextArea { self.adjust_pos_out_of_elements(candidate, true) } + pub(crate) fn beginning_of_next_word(&self) -> usize { + if self.cursor_pos >= self.text.len() { + return self.text.len(); + } + + let suffix = &self.text[self.cursor_pos..]; + let iter = suffix.char_indices(); + let at_whitespace = iter + .clone() + .next() + .map(|(_, ch)| ch.is_whitespace()) + .unwrap_or(false); + + if at_whitespace { + for (offset, ch) in iter { + if !ch.is_whitespace() { + let pos = self.cursor_pos + offset; + return self.adjust_pos_out_of_elements(pos, true); + } + } + return self.text.len(); + } + + let mut after_word = self.text.len(); + for (offset, ch) in iter { + if ch.is_whitespace() { + after_word = self.cursor_pos + offset; + break; + } + } + if after_word >= self.text.len() { + return self.text.len(); + } + + for (offset, ch) in self.text[after_word..].char_indices() { + if !ch.is_whitespace() { + let pos = after_word + offset; + return self.adjust_pos_out_of_elements(pos, true); + } + } + + self.text.len() + } + pub(crate) fn end_of_next_word(&self) -> usize { - let Some(first_non_ws) = self.text[self.cursor_pos..].find(|c: char| !c.is_whitespace()) - else { + self.end_of_next_word_from(self.cursor_pos) + } + + fn end_of_next_word_from(&self, start: usize) -> usize { + if start >= self.text.len() { + return self.text.len(); + } + let suffix = &self.text[start..]; + let Some(first_non_ws) = suffix.find(|c: char| !c.is_whitespace()) else { return self.text.len(); }; - let word_start = self.cursor_pos + first_non_ws; + let word_start = start + first_non_ws; let candidate = match self.text[word_start..].find(|c: char| c.is_whitespace()) { Some(rel_idx) => word_start + rel_idx, None => self.text.len(), @@ -977,6 +1047,46 @@ impl TextArea { self.adjust_pos_out_of_elements(candidate, false) } + pub(crate) fn end_of_next_word_inclusive(&self) -> usize { + if self.text.is_empty() { + return 0; + } + let len = self.text.len(); + let current = self.cursor_pos.min(len); + if current == len { + return len; + } + + let mut search_start = current; + + if let Some(ch) = self.text[current..].chars().next() { + if !ch.is_whitespace() { + let next_boundary = self.next_atomic_boundary(current); + let next_char = self.text[next_boundary..].chars().next(); + if next_char.is_none_or(char::is_whitespace) { + let idx = next_boundary; + search_start = self.text[idx..] + .char_indices() + .find(|&(_, c)| !c.is_whitespace()) + .map(|(offset, _)| idx + offset) + .unwrap_or(len); + if search_start >= len { + return current; + } + } + } + } else { + return current; + } + + let exclusive = self.end_of_next_word_from(search_start); + if exclusive == 0 { + 0 + } else { + self.prev_atomic_boundary(exclusive) + } + } + fn adjust_pos_out_of_elements(&self, pos: usize, prefer_start: bool) -> usize { if let Some(idx) = self.find_element_containing(pos) { let e = &self.elements[idx]; @@ -1591,15 +1701,23 @@ mod tests { let after_alpha = t.text().find("alpha").unwrap() + "alpha".len(); t.set_cursor(after_alpha); assert_eq!(t.beginning_of_previous_word(), 2); // skip initial spaces + assert_eq!(t.beginning_of_next_word(), t.text().find("beta").unwrap()); // Put cursor at start of beta let beta_start = t.text().find("beta").unwrap(); t.set_cursor(beta_start); assert_eq!(t.end_of_next_word(), beta_start + "beta".len()); + assert_eq!(t.beginning_of_next_word(), t.text().find("gamma").unwrap()); + assert_eq!( + t.end_of_next_word_inclusive(), + beta_start + "beta".len().saturating_sub(1) + ); // If at end, end_of_next_word returns len t.set_cursor(t.text().len()); assert_eq!(t.end_of_next_word(), t.text().len()); + assert_eq!(t.beginning_of_next_word(), t.text().len()); + assert_eq!(t.end_of_next_word_inclusive(), t.text().len()); } #[test] diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 908880f15e..c352748138 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -323,42 +323,38 @@ impl PagerView { return false; } - if self.vim_prefix.take().is_some() { - if let KeyCode::Char(ch) = key_event.code { - if ch.to_ascii_lowercase() == 'g' { - self.scroll_offset = 0; - Self::request_redraw(tui); - return true; - } - } + if self.vim_prefix.take().is_some() + && let KeyCode::Char(ch) = key_event.code + && ch.eq_ignore_ascii_case(&'g') + { + self.scroll_offset = 0; + Self::request_redraw(tui); + return true; } let mut handled = false; - match key_event.code { - KeyCode::Char(ch) => { - let lower = ch.to_ascii_lowercase(); - let shift = key_event.modifiers.contains(KeyModifiers::SHIFT); - handled = match lower { - 'j' => { - self.scroll_lines(1); - true - } - 'k' => { - self.scroll_lines(-1); - true - } - 'g' if shift => { - self.scroll_offset = usize::MAX; - true - } - 'g' => { - self.vim_prefix = Some(VimPrefix::G); - true - } - _ => false, - }; - } - _ => {} + if let KeyCode::Char(ch) = key_event.code { + let lower = ch.to_ascii_lowercase(); + let shift = key_event.modifiers.contains(KeyModifiers::SHIFT); + handled = match lower { + 'j' => { + self.scroll_lines(1); + true + } + 'k' => { + self.scroll_lines(-1); + true + } + 'g' if shift => { + self.scroll_offset = usize::MAX; + true + } + 'g' => { + self.vim_prefix = Some(VimPrefix::G); + true + } + _ => false, + }; } if handled && !matches!(self.vim_prefix, Some(VimPrefix::G)) {