From d7758ddcda040954ac798da21a810ea9c6b221d0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 13:35:56 -0300 Subject: [PATCH 01/11] feat(tui): add reverse history search Add a Ctrl-R history search mode that uses the footer as the query input and previews matching history in the composer once the user starts typing. The search restores the original draft on Esc, accepts the preview on Enter, and updates shortcut overlay snapshots for the new command. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 857 ++++++++++++++---- .../src/bottom_pane/chat_composer_history.rs | 419 ++++++++- codex-rs/tui/src/bottom_pane/footer.rs | 39 +- ...er__tests__footer_mode_history_search.snap | 13 + ...__tests__footer_mode_shortcut_overlay.snap | 4 +- ...shortcuts_collaboration_modes_enabled.snap | 5 +- ...tests__footer_shortcuts_shift_and_esc.snap | 4 +- 7 files changed, 1160 insertions(+), 181 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0a82f7cf801..4d7a79db93b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -28,6 +28,9 @@ //! When recalling a persistent entry, only the text is restored. //! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like //! history traversal semantics instead of dropping to column 0. +//! `Ctrl+R` opens a reverse incremental search mode. The footer becomes the search input; once the +//! query is non-empty, the composer body previews the current match. `Enter` accepts the preview as +//! an editable draft and `Esc` restores the draft that was active when search started. //! //! Slash commands are staged for local history instead of being recorded immediately. Command //! recall is a two-phase handoff: stage the submitted slash text here, then record it after @@ -143,6 +146,9 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::chat_composer_history::HistoryEntry; +use super::chat_composer_history::HistoryEntryResponse; +use super::chat_composer_history::HistorySearchDirection; +use super::chat_composer_history::HistorySearchResult; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::command_popup::CommandPopupFlags; @@ -356,6 +362,7 @@ pub(crate) struct ChatComposer { status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. active_agent_label: Option, + history_search: Option, } #[derive(Clone, Debug)] @@ -364,6 +371,32 @@ struct FooterFlash { expires_at: Instant, } +#[derive(Clone, Debug)] +struct HistorySearchSession { + original_draft: ComposerDraft, + query: String, + status: HistorySearchStatus, +} + +#[derive(Clone, Debug)] +enum HistorySearchStatus { + Idle, + Searching, + Match, + NoMatch, +} + +#[derive(Clone, Debug)] +struct ComposerDraft { + text: String, + text_elements: Vec, + local_image_paths: Vec, + remote_image_urls: Vec, + mention_bindings: Vec, + pending_pastes: Vec<(String, String)>, + cursor: usize, +} + #[derive(Clone, Debug)] struct ComposerMentionBinding { mention: String, @@ -481,6 +514,7 @@ impl ChatComposer { status_line_value: None, status_line_enabled: false, active_agent_label: None, + history_search: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -648,6 +682,10 @@ impl ChatComposer { return None; } + if let Some(pos) = self.history_search_cursor_pos(area) { + return Some(pos); + } + let [_, _, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -677,13 +715,22 @@ impl ChatComposer { offset: usize, entry: Option, ) -> bool { - let Some(entry) = self.history.on_entry_response(log_id, offset, entry) else { - return false; - }; - // Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting - // attachments), but local in-session ↑/↓ history can rehydrate elements and image paths. - self.apply_history_entry(entry); - true + match self + .history + .on_entry_response(log_id, offset, entry, &self.app_event_tx) + { + HistoryEntryResponse::Found(entry) => { + // Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting + // attachments), but local in-session ↑/↓ history can rehydrate elements and image paths. + self.apply_history_entry(entry); + true + } + HistoryEntryResponse::Search(result) => { + self.apply_history_search_result(result); + true + } + HistoryEntryResponse::Ignored => false, + } } /// Integrate pasted text into the composer. @@ -970,6 +1017,45 @@ impl ChatComposer { self.sync_popups(); } + fn snapshot_draft(&self) -> ComposerDraft { + ComposerDraft { + text: self.textarea.text().to_string(), + text_elements: self.textarea.text_elements(), + local_image_paths: self + .attached_images + .iter() + .map(|img| img.path.clone()) + .collect(), + remote_image_urls: self.remote_image_urls.clone(), + mention_bindings: self.snapshot_mention_bindings(), + pending_pastes: self.pending_pastes.clone(), + cursor: self.textarea.cursor(), + } + } + + fn restore_draft(&mut self, draft: ComposerDraft) { + let ComposerDraft { + text, + text_elements, + local_image_paths, + remote_image_urls, + mention_bindings, + pending_pastes, + cursor, + } = draft; + self.set_remote_image_urls(remote_image_urls); + self.set_text_content_with_mention_bindings( + text, + text_elements, + local_image_paths, + mention_bindings, + ); + self.set_pending_pastes(pending_pastes); + self.textarea + .set_cursor(cursor.min(self.textarea.text().len())); + self.sync_popups(); + } + /// Update the placeholder text without changing input enablement. pub(crate) fn set_placeholder_text(&mut self, placeholder: String) { self.placeholder_text = placeholder; @@ -1235,6 +1321,14 @@ impl ChatComposer { return (InputResult::None, false); } + if self.history_search.is_some() { + return self.handle_history_search_key(key_event); + } + + if Self::is_history_search_key(&key_event) { + return self.begin_history_search(); + } + 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), @@ -1248,7 +1342,241 @@ impl ChatComposer { /// 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) + self.history_search.is_some() || !matches!(self.active_popup, ActivePopup::None) + } + + #[cfg(test)] + fn history_search_active(&self) -> bool { + self.history_search.is_some() + } + + fn is_history_search_key(key_event: &KeyEvent) -> bool { + matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r') + ) || matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{0012}'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + } + + fn is_history_search_forward_key(key_event: &KeyEvent) -> bool { + matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s') + ) || matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{0013}'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + } + + fn begin_history_search(&mut self) -> (InputResult, bool) { + if self.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.current_file_query = None; + } + self.active_popup = ActivePopup::None; + self.selected_remote_image_index = None; + self.history_search = Some(HistorySearchSession { + original_draft: self.snapshot_draft(), + query: String::new(), + status: HistorySearchStatus::Idle, + }); + self.history.reset_search(); + (InputResult::None, true) + } + + fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if key_event.kind == KeyEventKind::Release { + return (InputResult::None, false); + } + + if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) { + let result = self.history_search_in_direction(HistorySearchDirection::Older); + return (result, true); + } + + if Self::is_history_search_forward_key(&key_event) + || matches!(key_event.code, KeyCode::Down) + { + let result = self.history_search_in_direction(HistorySearchDirection::Newer); + return (result, true); + } + + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if self + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + { + self.history_search = None; + self.history.reset_search(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.move_cursor_to_end(); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Backspace, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + if let Some(search) = self.history_search.as_ref() { + let mut query = search.query.clone(); + query.pop(); + self.update_history_search_query(query); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.update_history_search_query(String::new()); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) => { + if let Some(search) = self.history_search.as_ref() { + let mut query = search.query.clone(); + query.push(ch); + self.update_history_search_query(query); + } + (InputResult::None, true) + } + _ => (InputResult::None, true), + } + } + + fn history_search_in_direction(&mut self, direction: HistorySearchDirection) -> InputResult { + let Some((query, original_draft)) = self + .history_search + .as_ref() + .map(|search| (search.query.clone(), search.original_draft.clone())) + else { + return InputResult::None; + }; + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + self.restore_draft(original_draft); + return InputResult::None; + } + let result = self.history.search( + &query, + direction, + /*restart*/ false, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + InputResult::None + } + + fn update_history_search_query(&mut self, query: String) { + let Some(original_draft) = self + .history_search + .as_ref() + .map(|search| search.original_draft.clone()) + else { + return; + }; + if let Some(search) = self.history_search.as_mut() { + search.query = query.clone(); + search.status = HistorySearchStatus::Searching; + } + self.restore_draft(original_draft); + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + return; + } + let result = self.history.search( + &query, + HistorySearchDirection::Older, + /*restart*/ true, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + } + + fn cancel_history_search(&mut self) { + if let Some(search) = self.history_search.take() { + self.history.reset_search(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.restore_draft(search.original_draft); + } + } + + fn apply_history_search_result(&mut self, result: HistorySearchResult) { + match result { + HistorySearchResult::Found(entry) => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + self.apply_history_entry(entry); + } + HistorySearchResult::Pending => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Searching; + } + } + HistorySearchResult::NotFound => { + let original_draft = self + .history_search + .as_ref() + .map(|search| search.original_draft.clone()); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::NoMatch; + } + if let Some(original_draft) = original_draft { + self.restore_draft(original_draft); + } + } + } } /// Handle key event when the slash-command popup is visible. @@ -2893,6 +3221,66 @@ impl ChatComposer { } } + fn history_search_footer_line(&self) -> Option> { + let search = self.history_search.as_ref()?; + let mut line = Line::from(vec![ + "reverse-i-search: ".dim(), + search.query.clone().cyan(), + ]); + match search.status { + HistorySearchStatus::Idle => {} + HistorySearchStatus::Searching => line.push_span(" searching".dim()), + HistorySearchStatus::Match => { + line.push_span(" "); + line.push_span(key_hint::plain(KeyCode::Enter)); + line.push_span(" accept ".dim()); + line.push_span(key_hint::plain(KeyCode::Esc)); + line.push_span(" cancel".dim()); + } + HistorySearchStatus::NoMatch => line.push_span(" no match".red()), + } + Some(line) + } + + fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let search = self.history_search.as_ref()?; + let [_, _, _, popup_rect] = self.layout_areas(area); + if popup_rect.is_empty() { + return None; + } + + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(&footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if hint_rect.is_empty() { + return None; + } + + let prompt_width = Line::from("reverse-i-search: ").width() as u16; + let query_width = Line::from(search.query.clone()).width() as u16; + let desired_x = hint_rect + .x + .saturating_add(FOOTER_INDENT_COLS as u16) + .saturating_add(prompt_width) + .saturating_add(query_width); + let max_x = hint_rect + .x + .saturating_add(hint_rect.width.saturating_sub(1)); + Some((desired_x.min(max_x), hint_rect.y)) + } + /// Resolve the effective footer mode via a small priority waterfall. /// /// The base mode is derived solely from whether the composer is empty: @@ -2900,6 +3288,10 @@ impl ChatComposer { /// modes (Esc hint, overlay, quit reminder) can override that base when /// their conditions are active. fn footer_mode(&self) -> FooterMode { + if self.history_search.is_some() { + return FooterMode::HistorySearch; + } + let base_mode = if self.is_empty() { FooterMode::ComposerEmpty } else { @@ -2907,6 +3299,7 @@ impl ChatComposer { }; match self.footer_mode { + FooterMode::HistorySearch => FooterMode::HistorySearch, FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => { @@ -2933,6 +3326,17 @@ impl ChatComposer { pub(crate) fn sync_popups(&mut self) { self.sync_slash_command_elements(); + if self.history_search.is_some() { + if self.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.current_file_query = None; + } + self.active_popup = ActivePopup::None; + self.dismissed_file_popup_token = None; + self.dismissed_mention_popup_token = None; + return; + } if !self.popups_enabled() { self.active_popup = ActivePopup::None; return; @@ -3500,6 +3904,10 @@ impl Renderable for ChatComposer { return None; } + if let Some(pos) = self.history_search_cursor_pos(area) { + return Some(pos); + } + let [_, _, textarea_rect, _] = self.layout_areas(area); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) @@ -3558,13 +3966,15 @@ impl ChatComposer { let show_shortcuts_hint = match footer_props.mode { FooterMode::ComposerEmpty => !self.is_in_paste_burst(), FooterMode::ComposerHasDraft => false, - FooterMode::QuitShortcutReminder + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; let show_queue_hint = match footer_props.mode { FooterMode::ComposerHasDraft => footer_props.is_task_running, - FooterMode::QuitShortcutReminder + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, @@ -3583,124 +3993,141 @@ impl ChatComposer { } else { popup_rect }; - let available_width = - hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; - let status_line_active = uses_passive_footer_status_layout(&footer_props); - let combined_status_line = if status_line_active { - passive_footer_status_line(&footer_props).map(ratatui::prelude::Stylize::dim) + if let Some(line) = self.history_search_footer_line() { + render_footer_line(hint_rect, buf, line); } else { - None - }; - let mut truncated_status_line = if status_line_active { - combined_status_line.as_ref().map(|line| { - truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) - }) - } else { - None - }; - let left_mode_indicator = if status_line_active { - None - } else { - self.collaboration_mode_indicator - }; - let mut left_width = if self.footer_flash_visible() { - self.footer_flash - .as_ref() - .map(|flash| flash.line.width() as u16) - .unwrap_or(0) - } else if let Some(items) = self.footer_hint_override.as_ref() { - footer_hint_items_width(items) - } else if status_line_active { - truncated_status_line - .as_ref() - .map(|line| line.width() as u16) - .unwrap_or(0) - } else { - footer_line_width( - &footer_props, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ) - }; - 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, - /*show_cycle_hint*/ 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 + let available_width = + hint_rect.width.saturating_sub(FOOTER_INDENT_COLS as u16) as usize; + let status_line_active = uses_passive_footer_status_layout(&footer_props); + let combined_status_line = if status_line_active { + passive_footer_status_line(&footer_props) + .map(ratatui::prelude::Stylize::dim) + } else { + None + }; + let mut truncated_status_line = if status_line_active { + combined_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), available_width) + }) } else { - compact + None + }; + let left_mode_indicator = if status_line_active { + None + } else { + self.collaboration_mode_indicator + }; + let mut left_width = if self.footer_flash_visible() { + self.footer_flash + .as_ref() + .map(|flash| flash.line.width() as u16) + .unwrap_or(0) + } else if let Some(items) = self.footer_hint_override.as_ref() { + footer_hint_items_width(items) + } else if status_line_active { + truncated_status_line + .as_ref() + .map(|line| line.width() as u16) + .unwrap_or(0) + } else { + footer_line_width( + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ) + }; + 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, + /*show_cycle_hint*/ 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 + } else { + compact + } + } else { + Some(context_window_line( + footer_props.context_window_percent, + footer_props.context_window_used_tokens, + )) + }; + let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); + if status_line_active + && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) + && left_width > max_left + && let Some(line) = combined_status_line.as_ref().map(|line| { + truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) + }) + { + left_width = line.width() as u16; + truncated_status_line = Some(line); } - } else { - Some(context_window_line( - footer_props.context_window_percent, - footer_props.context_window_used_tokens, - )) - }; - let right_width = right_line.as_ref().map(|l| l.width() as u16).unwrap_or(0); - if status_line_active - && let Some(max_left) = max_left_width_for_right(hint_rect, right_width) - && left_width > max_left - && let Some(line) = combined_status_line.as_ref().map(|line| { - truncate_line_with_ellipsis_if_overflow(line.clone(), max_left as usize) - }) - { - left_width = line.width() as u16; - truncated_status_line = Some(line); - } - let can_show_left_and_context = - can_show_left_with_context(hint_rect, left_width, right_width); - let has_override = - self.footer_flash_visible() || self.footer_hint_override.is_some(); - let single_line_layout = if has_override || status_line_active { - None - } else { - match footer_props.mode { - FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => { - // Both of these modes render the single-line footer style (with - // either the shortcuts hint or the optional queue hint). We still - // want the single-line collapse rules so the mode label can win over - // the context indicator on narrow widths. - Some(single_line_footer_layout( - hint_rect, - right_width, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - )) + let can_show_left_and_context = + can_show_left_with_context(hint_rect, left_width, right_width); + let has_override = + self.footer_flash_visible() || self.footer_hint_override.is_some(); + let single_line_layout = if has_override || status_line_active { + None + } else { + match footer_props.mode { + FooterMode::ComposerEmpty | FooterMode::ComposerHasDraft => { + // Both of these modes render the single-line footer style (with + // either the shortcuts hint or the optional queue hint). We still + // want the single-line collapse rules so the mode label can win over + // the context indicator on narrow widths. + Some(single_line_footer_layout( + hint_rect, + right_width, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + )) + } + FooterMode::EscHint + | FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay => None, } + }; + let show_right = if matches!( + footer_props.mode, FooterMode::EscHint - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay => None, - } - }; - let show_right = if matches!( - footer_props.mode, - FooterMode::EscHint - | FooterMode::QuitShortcutReminder - | FooterMode::ShortcutOverlay - ) { - false - } else { - single_line_layout - .as_ref() - .map(|(_, show_context)| *show_context) - .unwrap_or(can_show_left_and_context) - }; + | FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + ) { + false + } else { + single_line_layout + .as_ref() + .map(|(_, show_context)| *show_context) + .unwrap_or(can_show_left_and_context) + }; - if let Some((summary_left, _)) = single_line_layout { - match summary_left { - SummaryLeft::Default => { - if status_line_active { - if let Some(line) = truncated_status_line.clone() { - render_footer_line(hint_rect, buf, line); + if let Some((summary_left, _)) = single_line_layout { + match summary_left { + SummaryLeft::Default => { + if status_line_active { + if let Some(line) = truncated_status_line.clone() { + render_footer_line(hint_rect, buf, line); + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + left_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); + } } else { render_footer_from_props( hint_rect, @@ -3712,47 +4139,37 @@ impl ChatComposer { show_queue_hint, ); } - } else { - render_footer_from_props( - hint_rect, - buf, - &footer_props, - left_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); } + SummaryLeft::Custom(line) => { + render_footer_line(hint_rect, buf, line); + } + SummaryLeft::None => {} + } + } else if self.footer_flash_visible() { + if let Some(flash) = self.footer_flash.as_ref() { + flash.line.render(inset_footer_hint_area(hint_rect), buf); } - SummaryLeft::Custom(line) => { + } else if let Some(items) = self.footer_hint_override.as_ref() { + render_footer_hint_items(hint_rect, buf, items); + } else if status_line_active { + if let Some(line) = truncated_status_line { render_footer_line(hint_rect, buf, line); } - SummaryLeft::None => {} - } - } else if self.footer_flash_visible() { - if let Some(flash) = self.footer_flash.as_ref() { - flash.line.render(inset_footer_hint_area(hint_rect), buf); - } - } else if let Some(items) = self.footer_hint_override.as_ref() { - render_footer_hint_items(hint_rect, buf, items); - } else if status_line_active { - if let Some(line) = truncated_status_line { - render_footer_line(hint_rect, buf, line); + } else { + render_footer_from_props( + hint_rect, + buf, + &footer_props, + self.collaboration_mode_indicator, + show_cycle_hint, + show_shortcuts_hint, + show_queue_hint, + ); } - } else { - render_footer_from_props( - hint_rect, - buf, - &footer_props, - self.collaboration_mode_indicator, - show_cycle_hint, - show_shortcuts_hint, - show_queue_hint, - ); - } - if show_right && let Some(line) = &right_line { - render_context_right(hint_rect, buf, line); + if show_right && let Some(line) = &right_line { + render_context_right(hint_rect, buf, line); + } } } } @@ -4191,6 +4608,20 @@ mod tests { type_chars_humanlike(composer, &['h']); }, ); + + snapshot_composer_state( + "footer_mode_history_search", + /*enhanced_keys_supported*/ true, + |composer| { + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = composer + .handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + }, + ); } #[test] @@ -7140,6 +7571,118 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn history_search_opens_without_previewing_latest_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(composer.textarea.is_empty()); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + + #[test] + fn history_search_accepts_matching_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn history_search_esc_restores_original_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(/*pos*/ 2); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "draft"); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "remembered command"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.textarea.cursor(), 2); + } + + #[test] + fn history_search_no_match_restores_preview_but_keeps_search_open() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['z', 'z', 'z'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + #[test] fn set_text_content_reattaches_images_without_placeholder_metadata() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index cc212b5dbbd..aea0ccf9263 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -107,6 +107,43 @@ pub(crate) struct ChatComposerHistory { /// treated as navigation versus normal cursor movement, together with the /// "cursor at line boundary" check in [`Self::should_handle_navigation`]. last_history_text: Option, + + /// Active incremental history search, if Ctrl+R search mode is open. + search: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HistorySearchDirection { + Older, + Newer, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum HistorySearchResult { + Found(HistoryEntry), + Pending, + NotFound, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum HistoryEntryResponse { + Found(HistoryEntry), + Search(HistorySearchResult), + Ignored, +} + +#[derive(Clone, Debug)] +struct HistorySearchState { + query: String, + query_lower: String, + selected_offset: Option, + awaiting: Option, +} + +#[derive(Clone, Copy, Debug)] +struct PendingHistorySearch { + offset: usize, + direction: HistorySearchDirection, } impl ChatComposerHistory { @@ -118,6 +155,7 @@ impl ChatComposerHistory { fetched_history: HashMap::new(), history_cursor: None, last_history_text: None, + search: None, } } @@ -129,6 +167,7 @@ impl ChatComposerHistory { self.local_history.clear(); self.history_cursor = None; self.last_history_text = None; + self.search = None; } /// Record a message submitted by the user in the current session so it can @@ -145,6 +184,7 @@ impl ChatComposerHistory { } self.history_cursor = None; self.last_history_text = None; + self.search = None; // Avoid inserting a duplicate if identical to the previous entry. if self.local_history.last().is_some_and(|prev| prev == &entry) { @@ -158,6 +198,11 @@ impl ChatComposerHistory { pub fn reset_navigation(&mut self) { self.history_cursor = None; self.last_history_text = None; + self.search = None; + } + + pub fn reset_search(&mut self) { + self.search = None; } /// Returns whether Up/Down should navigate history for the current textarea state. @@ -193,6 +238,7 @@ impl ChatComposerHistory { /// Handle . Returns true when the key was consumed and the caller /// should request a redraw. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { + self.search = None; let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { return None; @@ -210,6 +256,7 @@ impl ChatComposerHistory { /// Handle . pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { + self.search = None; let total_entries = self.history_entry_count + self.local_history.len(); if total_entries == 0 { return None; @@ -241,24 +288,218 @@ impl ChatComposerHistory { log_id: u64, offset: usize, entry: Option, - ) -> Option { + app_event_tx: &AppEventSender, + ) -> HistoryEntryResponse { if self.history_log_id != Some(log_id) { - return None; + return HistoryEntryResponse::Ignored; + } + + let entry = entry.map(HistoryEntry::new); + if let Some(entry) = entry.clone() { + self.fetched_history.insert(offset, entry); + } + + if self + .search + .as_ref() + .and_then(|search| search.awaiting) + .is_some_and(|pending| pending.offset == offset) + { + let direction = self + .search + .as_ref() + .and_then(|search| search.awaiting) + .map(|pending| pending.direction) + .unwrap_or(HistorySearchDirection::Older); + if let Some(entry) = entry + && self.search_matches(&entry) + { + return HistoryEntryResponse::Search(self.search_match(offset, entry)); + } + return HistoryEntryResponse::Search(self.advance_search_after( + offset, + direction, + app_event_tx, + )); } - let entry = HistoryEntry::new(entry?); - self.fetched_history.insert(offset, entry.clone()); if self.history_cursor == Some(offset as isize) { + let Some(entry) = entry else { + return HistoryEntryResponse::Ignored; + }; self.last_history_text = Some(entry.text.clone()); - return Some(entry); + return HistoryEntryResponse::Found(entry); } - None + + HistoryEntryResponse::Ignored + } + + pub fn search( + &mut self, + query: &str, + direction: HistorySearchDirection, + restart: bool, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let total_entries = self.total_entries(); + if total_entries == 0 { + self.search = Some(HistorySearchState::new(query)); + return HistorySearchResult::NotFound; + } + + let query_changed = self + .search + .as_ref() + .is_none_or(|search| search.query != query); + if !query_changed + && !restart + && self + .search + .as_ref() + .and_then(|search| search.awaiting) + .is_some() + { + return HistorySearchResult::Pending; + } + + if query_changed || restart || self.search.is_none() { + self.search = Some(HistorySearchState::new(query)); + } else if let Some(search) = self.search.as_mut() { + search.awaiting = None; + } + + let start_offset = + self.search_start_offset(total_entries, direction, query_changed || restart); + let Some(start_offset) = start_offset else { + return HistorySearchResult::NotFound; + }; + + self.advance_search_from(start_offset, direction, app_event_tx) } // --------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------- + fn total_entries(&self) -> usize { + self.history_entry_count + self.local_history.len() + } + + fn search_start_offset( + &self, + total_entries: usize, + direction: HistorySearchDirection, + restart: bool, + ) -> Option { + let selected = self + .search + .as_ref() + .and_then(|search| search.selected_offset); + match direction { + HistorySearchDirection::Older => { + if restart { + total_entries.checked_sub(1) + } else { + selected.and_then(|offset| offset.checked_sub(1)) + } + } + HistorySearchDirection::Newer => { + if restart { + Some(0) + } else { + selected + .and_then(|offset| offset.checked_add(1)) + .filter(|offset| *offset < total_entries) + } + } + } + } + + fn advance_search_after( + &mut self, + offset: usize, + direction: HistorySearchDirection, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let next_offset = match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => offset + .checked_add(1) + .filter(|next| *next < self.total_entries()), + }; + let Some(next_offset) = next_offset else { + return HistorySearchResult::NotFound; + }; + self.advance_search_from(next_offset, direction, app_event_tx) + } + + fn advance_search_from( + &mut self, + mut offset: usize, + direction: HistorySearchDirection, + app_event_tx: &AppEventSender, + ) -> HistorySearchResult { + let total_entries = self.total_entries(); + while offset < total_entries { + if let Some(entry) = self.entry_at_cached_offset(offset) { + if self.search_matches(&entry) { + return self.search_match(offset, entry); + } + } else if offset < self.history_entry_count + && let Some(log_id) = self.history_log_id + { + if let Some(search) = self.search.as_mut() { + search.awaiting = Some(PendingHistorySearch { offset, direction }); + } + app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest { + offset, + log_id, + })); + return HistorySearchResult::Pending; + } + + let next_offset = match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => { + offset.checked_add(1).filter(|next| *next < total_entries) + } + }; + let Some(next_offset) = next_offset else { + return HistorySearchResult::NotFound; + }; + offset = next_offset; + } + + HistorySearchResult::NotFound + } + + fn entry_at_cached_offset(&self, offset: usize) -> Option { + if offset >= self.history_entry_count { + self.local_history + .get(offset - self.history_entry_count) + .cloned() + } else { + self.fetched_history.get(&offset).cloned() + } + } + + fn search_matches(&self, entry: &HistoryEntry) -> bool { + let Some(search) = self.search.as_ref() else { + return false; + }; + search.query.is_empty() || entry.text.to_lowercase().contains(&search.query_lower) + } + + fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult { + self.history_cursor = Some(offset as isize); + self.last_history_text = Some(entry.text.clone()); + if let Some(search) = self.search.as_mut() { + search.selected_offset = Some(offset); + search.awaiting = None; + } + HistorySearchResult::Found(entry) + } + fn populate_history_at_index( &mut self, global_idx: usize, @@ -287,6 +528,17 @@ impl ChatComposerHistory { } } +impl HistorySearchState { + fn new(query: &str) -> Self { + Self { + query: query.to_string(), + query_lower: query.to_lowercase(), + selected_offset: None, + awaiting: None, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -351,8 +603,13 @@ mod tests { // Inject the async response. assert_eq!( - Some(HistoryEntry::new("latest".to_string())), - history.on_entry_response(/*log_id*/ 1, /*offset*/ 2, Some("latest".into())) + HistoryEntryResponse::Found(HistoryEntry::new("latest".to_string())), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("latest".into()), + &tx + ) ); // Next Up should move to offset 1. @@ -372,8 +629,150 @@ mod tests { ); assert_eq!( - Some(HistoryEntry::new("older".to_string())), - history.on_entry_response(/*log_id*/ 1, /*offset*/ 1, Some("older".into())) + HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("older".into()), + &tx + ) + ); + } + + #[test] + fn search_matches_local_history_and_cycles_without_wrapping() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); + history.record_local_submission(HistoryEntry::new("git diff".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::NotFound, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + } + + #[test] + fn search_fetches_persistent_history_until_match() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "older", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let AppEvent::CodexOp(op) = rx.try_recv().expect("expected latest lookup") else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 2, + }, + op + ); + + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("latest".into()), + &tx + ) + ); + let AppEvent::CodexOp(op) = rx.try_recv().expect("expected next lookup") else { + panic!("unexpected event variant"); + }; + assert_eq!( + Op::GetHistoryEntryRequest { + log_id: 1, + offset: 1, + }, + op + ); + + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "older command".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("older command".into()), + &tx + ) + ); + } + + #[test] + fn search_is_case_insensitive_and_empty_query_finds_latest() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("Build Release".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), + history.search( + "release", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("Build Release".to_string())), + history.search( + "", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) ); } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 194f662b32c..958f5c2ead5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -130,6 +130,8 @@ impl CollaborationModeIndicator { /// (for example, showing `QuitShortcutReminder` only while its timer is active). #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum FooterMode { + /// Single-line incremental history search prompt shown while Ctrl+R search is active. + HistorySearch, /// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D). QuitShortcutReminder, /// Multi-line shortcut overlay shown after pressing `?`. @@ -179,6 +181,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { FooterMode::EscHint | FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder + | FooterMode::HistorySearch | FooterMode::ComposerHasDraft => FooterMode::ComposerEmpty, other => other, } @@ -188,13 +191,15 @@ pub(crate) fn footer_height(props: &FooterProps) -> u16 { let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => false, - FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => { - false - } + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, FooterMode::QuitShortcutReminder + | FooterMode::HistorySearch | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, @@ -593,6 +598,7 @@ fn footer_from_props_lines( FooterMode::QuitShortcutReminder => { vec![quit_shortcut_reminder_line(props.quit_shortcut_key)] } + FooterMode::HistorySearch => vec![Line::from("reverse-i-search: ").dim()], FooterMode::ComposerEmpty => { let state = LeftSideState { hint: if show_shortcuts_hint { @@ -666,9 +672,10 @@ pub(crate) fn shows_passive_footer_line(props: &FooterProps) -> bool { match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => !props.is_task_running, - FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => { - false - } + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder + | FooterMode::ShortcutOverlay + | FooterMode::EscHint => false, } } @@ -756,6 +763,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); + let mut history_search = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); let mut change_mode = Line::from(""); @@ -771,6 +779,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, + ShortcutId::HistorySearch => history_search = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, ShortcutId::ChangeMode => change_mode = text, @@ -787,6 +796,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { paste_image, external_editor, edit_previous, + history_search, quit, ]; if change_mode.width() > 0 { @@ -869,6 +879,7 @@ enum ShortcutId { PasteImage, ExternalEditor, EditPrevious, + HistorySearch, Quit, ShowTranscript, ChangeMode, @@ -1027,6 +1038,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: "", }, + ShortcutDescriptor { + id: ShortcutId::HistorySearch, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('r')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " search history", + }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -1086,13 +1106,15 @@ mod tests { let show_shortcuts_hint = match props.mode { FooterMode::ComposerEmpty => true, FooterMode::ComposerHasDraft => false, - FooterMode::QuitShortcutReminder + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, }; let show_queue_hint = match props.mode { FooterMode::ComposerHasDraft => props.is_task_running, - FooterMode::QuitShortcutReminder + FooterMode::HistorySearch + | FooterMode::QuitShortcutReminder | FooterMode::ComposerEmpty | FooterMode::ShortcutOverlay | FooterMode::EscHint => false, @@ -1227,6 +1249,7 @@ mod tests { && !matches!( props.mode, FooterMode::EscHint + | FooterMode::HistorySearch | FooterMode::QuitShortcutReminder | FooterMode::ShortcutOverlay ); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap new file mode 100644 index 00000000000..38b676862bd --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› cargo test " +" " +" " +" " +" " +" " +" " +" reverse-i-search: c enter accept esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 8486a9ec6f3..8db427fe3b4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -14,5 +14,5 @@ expression: terminal.backend() " shift + enter for newline tab to queue message " " @ for file paths ctrl + v to paste images " " ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" ctrl + r search history ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index 1bb213bbec8..b2b7f1349eb 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -6,5 +6,6 @@ expression: terminal.backend() " ctrl + j for newline tab to queue message " " @ for file paths ctrl + v to paste images " " ctrl + g to edit in external editor esc esc to edit previous message " -" ctrl + c to exit shift + tab to change mode " -" ctrl + t to view transcript " +" ctrl + r search history ctrl + c to exit " +" shift + tab to change mode " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index c1f00d44377..e955715ec04 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -6,5 +6,5 @@ expression: terminal.backend() " shift + enter for newline tab to queue message " " @ for file paths ctrl + v to paste images " " ctrl + g to edit in external editor esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" ctrl + r search history ctrl + c to exit " +" ctrl + t to view transcript " From 52ce60c9fed8d12540b618d630fe0d18fb376552 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 13:54:13 -0300 Subject: [PATCH 02/11] feat(tui): highlight history search matches Add render-only highlighting for Ctrl-R history search matches in the composer preview so accepted drafts keep their plain text. Highlight ranges are computed case-insensitively and cleared when search mode exits. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 170 +++++++++++++++++- codex-rs/tui/src/bottom_pane/textarea.rs | 78 +++++++- 2 files changed, 242 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 4d7a79db93b..73d9a9d3c60 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -136,6 +136,8 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Margin; use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -3242,6 +3244,65 @@ impl ChatComposer { Some(line) } + fn history_search_highlight_ranges(&self) -> Vec> { + let Some(search) = self.history_search.as_ref() else { + return Vec::new(); + }; + if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { + return Vec::new(); + } + Self::case_insensitive_match_ranges(self.textarea.text(), &search.query) + } + + fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + let query_lower = query + .chars() + .flat_map(char::to_lowercase) + .collect::(); + if query_lower.is_empty() { + return Vec::new(); + } + + let mut folded = String::new(); + let mut folded_spans: Vec<(Range, Range)> = Vec::new(); + for (original_start, ch) in text.char_indices() { + let original_range = original_start..original_start + ch.len_utf8(); + for lower in ch.to_lowercase() { + let folded_start = folded.len(); + folded.push(lower); + folded_spans.push((folded_start..folded.len(), original_range.clone())); + } + } + + let mut ranges = Vec::new(); + let mut search_from = 0; + while search_from <= folded.len() + && let Some(relative_start) = folded[search_from..].find(&query_lower) + { + let folded_start = search_from + relative_start; + let folded_end = folded_start + query_lower.len(); + if let Some((_, first_original)) = folded_spans.iter().find(|(folded_range, _)| { + folded_range.end > folded_start && folded_range.start < folded_end + }) { + let original_end = folded_spans + .iter() + .rev() + .find(|(folded_range, _)| { + folded_range.end > folded_start && folded_range.start < folded_end + }) + .map(|(_, original_range)| original_range.end) + .unwrap_or(first_original.end); + ranges.push(first_original.start..original_end); + } + search_from = folded_end; + } + ranges + } + fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let search = self.history_search.as_ref()?; let [_, _, _, popup_rect] = self.layout_areas(area); @@ -4245,10 +4306,44 @@ impl ChatComposer { } else if is_zellij && textarea_is_empty { buf.set_style(textarea_rect, textarea_style); } else if is_zellij { - self.textarea - .render_ref_styled(textarea_rect, buf, &mut state, textarea_style); + let highlight_ranges = self.history_search_highlight_ranges(); + if highlight_ranges.is_empty() { + self.textarea + .render_ref_styled(textarea_rect, buf, &mut state, textarea_style); + } else { + let highlight_style = + textarea_style.add_modifier(Modifier::REVERSED | Modifier::BOLD); + let highlights = highlight_ranges + .into_iter() + .map(|range| (range, highlight_style)) + .collect::>(); + self.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + textarea_style, + &highlights, + ); + } } else { - StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + let highlight_ranges = self.history_search_highlight_ranges(); + if highlight_ranges.is_empty() { + StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); + } else { + let highlight_style = + Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + let highlights = highlight_ranges + .into_iter() + .map(|range| (range, highlight_style)) + .collect::>(); + self.textarea.render_ref_styled_with_highlights( + textarea_rect, + buf, + &mut state, + Style::default(), + &highlights, + ); + } } if textarea_is_empty { let text = if self.input_enabled { @@ -7594,6 +7689,19 @@ mod tests { assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); } + #[test] + fn history_search_match_ranges_are_case_insensitive() { + assert_eq!( + ChatComposer::case_insensitive_match_ranges("git status git", "GIT"), + vec![0..3, 11..14] + ); + assert_eq!( + ChatComposer::case_insensitive_match_ranges("aİ i", "i"), + vec![1..3, 4..5] + ); + assert!(ChatComposer::case_insensitive_match_ranges("git", "").is_empty()); + } + #[test] fn history_search_accepts_matching_entry() { let (tx, _rx) = unbounded_channel::(); @@ -7629,6 +7737,62 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn history_search_highlights_matches_until_accepted() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + let area = Rect::new(0, 0, 60, 8); + let [_, _, textarea_rect, _] = composer.layout_areas(area); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let x = textarea_rect.x; + let y = textarea_rect.y; + assert_eq!(buf[(x, y)].symbol(), "g"); + for offset in 0..3 { + let modifier = buf[(x + offset, y)].style().add_modifier; + assert!(modifier.contains(Modifier::REVERSED)); + assert!(modifier.contains(Modifier::BOLD)); + } + assert!( + !buf[(x + 3, y)] + .style() + .add_modifier + .contains(Modifier::REVERSED) + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area); + let mut accepted_buf = Buffer::empty(area); + composer.render(area, &mut accepted_buf); + for offset in 0..3 { + let modifier = accepted_buf + [(accepted_textarea_rect.x + offset, accepted_textarea_rect.y)] + .style() + .add_modifier; + assert!(!modifier.contains(Modifier::REVERSED)); + assert!(!modifier.contains(Modifier::BOLD)); + } + } + #[test] fn history_search_esc_restores_original_draft() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 93fe62c5853..2af4b3145f9 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -1366,7 +1366,7 @@ impl TextArea { impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len(), Style::default()); + self.render_lines(area, buf, &lines, 0..lines.len(), Style::default(), &[]); } } @@ -1380,7 +1380,7 @@ impl StatefulWidgetRef for &TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end, Style::default()); + self.render_lines(area, buf, &lines, start..end, Style::default(), &[]); } } @@ -1417,7 +1417,28 @@ impl TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end, base_style); + self.render_lines(area, buf, &lines, start..end, base_style, &[]); + } + + /// Render the textarea with `base_style` plus additional render-only highlight ranges. + /// + /// Highlight ranges are byte ranges in `self.text`. They affect only the buffer rendering and + /// do not mutate the editable text, cursor, element metadata, or wrapping cache. + pub(crate) fn render_ref_styled_with_highlights( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut TextAreaState, + base_style: Style, + highlights: &[(Range, Style)], + ) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + self.render_lines(area, buf, &lines, start..end, base_style, highlights); } fn render_lines( @@ -1427,6 +1448,7 @@ impl TextArea { lines: &[Range], range: std::ops::Range, base_style: Style, + highlights: &[(Range, Style)], ) { for (row, idx) in range.enumerate() { let r = &lines[idx]; @@ -1449,6 +1471,19 @@ impl TextArea { let style = base_style.fg(ratatui::style::Color::Cyan); buf.set_string(area.x + x_off, y, styled, style); } + + // Overlay render-only highlight ranges last so transient search highlighting remains + // visible even when it intersects attachment placeholders or other styled elements. + for (highlight_range, style) in highlights { + let overlap_start = highlight_range.start.max(line_range.start); + let overlap_end = highlight_range.end.min(line_range.end); + if overlap_start >= overlap_end { + continue; + } + let highlighted = &self.text[overlap_start..overlap_end]; + let x_off = self.text[line_range.start..overlap_start].width() as u16; + buf.set_string(area.x + x_off, y, highlighted, *style); + } } } @@ -2187,6 +2222,43 @@ mod tests { assert!(state.scroll < effective_lines); } + #[test] + fn render_highlights_apply_style_without_mutating_text() { + let t = ta_with("hello world"); + let area = Rect::new(0, 0, 20, 1); + let mut state = TextAreaState::default(); + let mut buf = Buffer::empty(area); + let highlight_style = Style::default().add_modifier(ratatui::style::Modifier::REVERSED); + + t.render_ref_styled_with_highlights( + area, + &mut buf, + &mut state, + Style::default(), + &[(6..11, highlight_style)], + ); + + assert_eq!(t.text(), "hello world"); + assert!( + !buf[(0, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + buf[(6, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + assert!( + buf[(10, 0)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::REVERSED) + ); + } + #[test] fn cursor_pos_with_state_basic_and_scroll_behaviors() { // Case 1: No wrapping needed, height fits — scroll ignored, y maps directly. From d274495de073a32624ab2567f8f2a88a1cc5dfaf Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 14:04:47 -0300 Subject: [PATCH 03/11] feat(tui): polish history search footer hints Improve the reverse history search footer so the active actions read more cleanly while staying within the existing TUI style system. The `enter` and `esc` hints now use cyan bold emphasis, the labels stay dim, and the footer snapshot and style assertions cover the new presentation. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 74 ++++++++++++++++++- ...er__tests__footer_mode_history_search.snap | 2 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 73d9a9d3c60..d2f136fd1f7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3233,10 +3233,11 @@ impl ChatComposer { HistorySearchStatus::Idle => {} HistorySearchStatus::Searching => line.push_span(" searching".dim()), HistorySearchStatus::Match => { - line.push_span(" "); - line.push_span(key_hint::plain(KeyCode::Enter)); - line.push_span(" accept ".dim()); - line.push_span(key_hint::plain(KeyCode::Esc)); + line.push_span(" ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Enter)); + line.push_span(" accept".dim()); + line.push_span(" · ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Esc)); line.push_span(" cancel".dim()); } HistorySearchStatus::NoMatch => line.push_span(" no match".red()), @@ -3244,6 +3245,10 @@ impl ChatComposer { Some(line) } + fn history_search_action_key_span(key: KeyCode) -> Span<'static> { + Span::from(key_hint::plain(key)).cyan().bold().not_dim() + } + fn history_search_highlight_ranges(&self) -> Vec> { let Some(search) = self.history_search.as_ref() else { return Vec::new(); @@ -7737,6 +7742,67 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn history_search_footer_action_hints_are_emphasized() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + let line = composer + .history_search_footer_line() + .expect("expected history search footer line"); + assert_eq!( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>(), + vec![ + "reverse-i-search: ", + "c", + " ", + "enter", + " accept", + " · ", + "esc", + " cancel" + ] + ); + + let query_style = line.spans[1].style; + assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan)); + + let enter_style = line.spans[3].style; + assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(enter_style.add_modifier.contains(Modifier::BOLD)); + assert!(enter_style.sub_modifier.contains(Modifier::DIM)); + + let accept_style = line.spans[4].style; + assert!(accept_style.add_modifier.contains(Modifier::DIM)); + + let separator_style = line.spans[5].style; + assert!(separator_style.add_modifier.contains(Modifier::DIM)); + + let esc_style = line.spans[6].style; + assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(esc_style.add_modifier.contains(Modifier::BOLD)); + assert!(esc_style.sub_modifier.contains(Modifier::DIM)); + + let cancel_style = line.spans[7].style; + assert!(cancel_style.add_modifier.contains(Modifier::DIM)); + } + #[test] fn history_search_highlights_matches_until_accepted() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap index 38b676862bd..90cfba24a9b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_history_search.snap @@ -10,4 +10,4 @@ expression: terminal.backend() " " " " " " -" reverse-i-search: c enter accept esc cancel " +" reverse-i-search: c enter accept · esc cancel " From 4ef23fd0b9edd4e6e746f3ae1bb31d28aa243330 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 14:26:17 -0300 Subject: [PATCH 04/11] fix(tui): clamp history search navigation Keep Ctrl-R history search stable when navigation reaches the first or last matching entry instead of treating the boundary as no match. Track exhausted search directions so repeated boundary keypresses do not keep scanning or fetching history before the user changes direction. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 51 +++++ .../src/bottom_pane/chat_composer_history.rs | 191 +++++++++++++++++- 2 files changed, 231 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d2f136fd1f7..3031134437a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1566,6 +1566,11 @@ impl ChatComposer { search.status = HistorySearchStatus::Searching; } } + HistorySearchResult::AtBoundary => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + } HistorySearchResult::NotFound => { let original_draft = self .history_search @@ -7742,6 +7747,52 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } + #[test] + fn history_search_stays_on_single_match_at_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.history.record_local_submission(HistoryEntry::new( + "Find and fix a bug in @filename".to_string(), + )); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['b', 'u', 'g'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + + for _ in 0..3 { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + + for _ in 0..3 { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + } + #[test] fn history_search_footer_action_hints_are_emphasized() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index aea0ccf9263..a51552a3996 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -122,6 +122,7 @@ pub(crate) enum HistorySearchDirection { pub(crate) enum HistorySearchResult { Found(HistoryEntry), Pending, + AtBoundary, NotFound, } @@ -138,12 +139,15 @@ struct HistorySearchState { query_lower: String, selected_offset: Option, awaiting: Option, + exhausted_older: bool, + exhausted_newer: bool, } #[derive(Clone, Copy, Debug)] struct PendingHistorySearch { offset: usize, direction: HistorySearchDirection, + boundary_if_exhausted: bool, } impl ChatComposerHistory { @@ -305,12 +309,15 @@ impl ChatComposerHistory { .and_then(|search| search.awaiting) .is_some_and(|pending| pending.offset == offset) { - let direction = self + let pending = self .search .as_ref() .and_then(|search| search.awaiting) - .map(|pending| pending.direction) - .unwrap_or(HistorySearchDirection::Older); + .unwrap_or(PendingHistorySearch { + offset, + direction: HistorySearchDirection::Older, + boundary_if_exhausted: false, + }); if let Some(entry) = entry && self.search_matches(&entry) { @@ -318,7 +325,8 @@ impl ChatComposerHistory { } return HistoryEntryResponse::Search(self.advance_search_after( offset, - direction, + pending.direction, + pending.boundary_if_exhausted, app_event_tx, )); } @@ -368,13 +376,34 @@ impl ChatComposerHistory { search.awaiting = None; } + let boundary_if_exhausted = !restart + && self + .search + .as_ref() + .and_then(|search| search.selected_offset) + .is_some(); + if boundary_if_exhausted + && self + .search + .as_ref() + .is_some_and(|search| search.is_exhausted(direction)) + { + return HistorySearchResult::AtBoundary; + } + let start_offset = self.search_start_offset(total_entries, direction, query_changed || restart); let Some(start_offset) = start_offset else { - return HistorySearchResult::NotFound; + return self.exhausted_search_result(direction, boundary_if_exhausted); }; - self.advance_search_from(start_offset, direction, app_event_tx) + let result = + self.advance_search_from(start_offset, direction, boundary_if_exhausted, app_event_tx); + if matches!(result, HistorySearchResult::NotFound) { + self.exhausted_search_result(direction, boundary_if_exhausted) + } else { + result + } } // --------------------------------------------------------------------- @@ -419,6 +448,7 @@ impl ChatComposerHistory { &mut self, offset: usize, direction: HistorySearchDirection, + boundary_if_exhausted: bool, app_event_tx: &AppEventSender, ) -> HistorySearchResult { let next_offset = match direction { @@ -428,15 +458,22 @@ impl ChatComposerHistory { .filter(|next| *next < self.total_entries()), }; let Some(next_offset) = next_offset else { - return HistorySearchResult::NotFound; + return self.exhausted_search_result(direction, boundary_if_exhausted); }; - self.advance_search_from(next_offset, direction, app_event_tx) + let result = + self.advance_search_from(next_offset, direction, boundary_if_exhausted, app_event_tx); + if matches!(result, HistorySearchResult::NotFound) { + self.exhausted_search_result(direction, boundary_if_exhausted) + } else { + result + } } fn advance_search_from( &mut self, mut offset: usize, direction: HistorySearchDirection, + boundary_if_exhausted: bool, app_event_tx: &AppEventSender, ) -> HistorySearchResult { let total_entries = self.total_entries(); @@ -449,7 +486,11 @@ impl ChatComposerHistory { && let Some(log_id) = self.history_log_id { if let Some(search) = self.search.as_mut() { - search.awaiting = Some(PendingHistorySearch { offset, direction }); + search.awaiting = Some(PendingHistorySearch { + offset, + direction, + boundary_if_exhausted, + }); } app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest { offset, @@ -496,10 +537,31 @@ impl ChatComposerHistory { if let Some(search) = self.search.as_mut() { search.selected_offset = Some(offset); search.awaiting = None; + search.exhausted_older = false; + search.exhausted_newer = false; } HistorySearchResult::Found(entry) } + fn exhausted_search_result( + &mut self, + direction: HistorySearchDirection, + boundary_if_exhausted: bool, + ) -> HistorySearchResult { + if let Some(search) = self.search.as_mut() { + search.awaiting = None; + if boundary_if_exhausted { + search.mark_exhausted(direction); + } + } + + if boundary_if_exhausted { + HistorySearchResult::AtBoundary + } else { + HistorySearchResult::NotFound + } + } + fn populate_history_at_index( &mut self, global_idx: usize, @@ -535,6 +597,22 @@ impl HistorySearchState { query_lower: query.to_lowercase(), selected_offset: None, awaiting: None, + exhausted_older: false, + exhausted_newer: false, + } + } + + fn is_exhausted(&self, direction: HistorySearchDirection) -> bool { + match direction { + HistorySearchDirection::Older => self.exhausted_older, + HistorySearchDirection::Newer => self.exhausted_newer, + } + } + + fn mark_exhausted(&mut self, direction: HistorySearchDirection) { + match direction { + HistorySearchDirection::Older => self.exhausted_older = true, + HistorySearchDirection::Newer => self.exhausted_newer = true, } } } @@ -640,7 +718,7 @@ mod tests { } #[test] - fn search_matches_local_history_and_cycles_without_wrapping() { + fn search_matches_local_history_and_stops_at_boundaries() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); @@ -668,7 +746,16 @@ mod tests { ) ); assert_eq!( - HistorySearchResult::NotFound, + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, history.search( "git", HistorySearchDirection::Older, @@ -685,6 +772,88 @@ mod tests { &tx ) ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + } + + #[test] + fn repeated_boundary_search_does_not_refetch_persistent_history() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let _ = rx.try_recv().expect("expected latest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "needle latest".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("needle latest".into()), + &tx, + ) + ); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + let _ = rx.try_recv().expect("expected next older lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("not a match".into()), + &tx, + ) + ); + let _ = rx.try_recv().expect("expected oldest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::AtBoundary), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 0, + Some("also not a match".into()), + &tx, + ) + ); + assert!(rx.try_recv().is_err()); + + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert!(rx.try_recv().is_err()); } #[test] From eb4b8337a0699eb1fc46663cedb8f175598c2a04 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 14:38:04 -0300 Subject: [PATCH 05/11] fix(tui): dedupe reverse search results Track unique prompt text during Ctrl-R history search so repeated history entries do not appear as separate matches. Cache discovered unique matches so older/newer navigation stays bounded and reversible while skipping duplicate offsets. --- .../src/bottom_pane/chat_composer_history.rs | 238 +++++++++++++++++- 1 file changed, 237 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index a51552a3996..7d6f8672047 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::path::PathBuf; use crate::app_event::AppEvent; @@ -138,11 +139,20 @@ struct HistorySearchState { query: String, query_lower: String, selected_offset: Option, + unique_matches: Vec, + selected_match_index: Option, + seen_texts: HashSet, awaiting: Option, exhausted_older: bool, exhausted_newer: bool, } +#[derive(Clone, Debug)] +struct UniqueHistoryMatch { + offset: usize, + entry: HistoryEntry, +} + #[derive(Clone, Copy, Debug)] struct PendingHistorySearch { offset: usize, @@ -320,6 +330,7 @@ impl ChatComposerHistory { }); if let Some(entry) = entry && self.search_matches(&entry) + && self.search_result_is_unique(&entry) { return HistoryEntryResponse::Search(self.search_match(offset, entry)); } @@ -382,6 +393,12 @@ impl ChatComposerHistory { .as_ref() .and_then(|search| search.selected_offset) .is_some(); + if !restart + && !query_changed + && let Some(result) = self.select_cached_unique_match(direction) + { + return result; + } if boundary_if_exhausted && self .search @@ -479,7 +496,7 @@ impl ChatComposerHistory { let total_entries = self.total_entries(); while offset < total_entries { if let Some(entry) = self.entry_at_cached_offset(offset) { - if self.search_matches(&entry) { + if self.search_matches(&entry) && self.search_result_is_unique(&entry) { return self.search_match(offset, entry); } } else if offset < self.history_entry_count @@ -531,11 +548,18 @@ impl ChatComposerHistory { search.query.is_empty() || entry.text.to_lowercase().contains(&search.query_lower) } + fn search_result_is_unique(&self, entry: &HistoryEntry) -> bool { + self.search + .as_ref() + .is_none_or(|search| !search.seen_texts.contains(entry.text.as_str())) + } + fn search_match(&mut self, offset: usize, entry: HistoryEntry) -> HistorySearchResult { self.history_cursor = Some(offset as isize); self.last_history_text = Some(entry.text.clone()); if let Some(search) = self.search.as_mut() { search.selected_offset = Some(offset); + search.record_match(offset, &entry); search.awaiting = None; search.exhausted_older = false; search.exhausted_newer = false; @@ -543,6 +567,31 @@ impl ChatComposerHistory { HistorySearchResult::Found(entry) } + fn select_cached_unique_match( + &mut self, + direction: HistorySearchDirection, + ) -> Option { + let next_index = { + let search = self.search.as_ref()?; + let selected_index = search.selected_match_index?; + match direction { + HistorySearchDirection::Older => { + let next_index = selected_index + 1; + (next_index < search.unique_matches.len()).then_some(next_index)? + } + HistorySearchDirection::Newer => selected_index.checked_sub(1)?, + } + }; + + let history_match = self.search.as_ref()?.unique_matches[next_index].clone(); + self.history_cursor = Some(history_match.offset as isize); + self.last_history_text = Some(history_match.entry.text.clone()); + if let Some(search) = self.search.as_mut() { + search.select_match(next_index); + } + Some(HistorySearchResult::Found(history_match.entry)) + } + fn exhausted_search_result( &mut self, direction: HistorySearchDirection, @@ -596,6 +645,9 @@ impl HistorySearchState { query: query.to_string(), query_lower: query.to_lowercase(), selected_offset: None, + unique_matches: Vec::new(), + selected_match_index: None, + seen_texts: HashSet::new(), awaiting: None, exhausted_older: false, exhausted_newer: false, @@ -615,6 +667,41 @@ impl HistorySearchState { HistorySearchDirection::Newer => self.exhausted_newer = true, } } + + fn record_match(&mut self, offset: usize, entry: &HistoryEntry) { + if let Some(index) = self + .unique_matches + .iter() + .position(|history_match| history_match.offset == offset) + { + self.select_match(index); + return; + } + + self.seen_texts.insert(entry.text.clone()); + let insert_index = self + .unique_matches + .partition_point(|history_match| history_match.offset > offset); + self.unique_matches.insert( + insert_index, + UniqueHistoryMatch { + offset, + entry: entry.clone(), + }, + ); + self.select_match(insert_index); + } + + fn select_match(&mut self, index: usize) { + let Some(history_match) = self.unique_matches.get(index) else { + return; + }; + self.selected_offset = Some(history_match.offset); + self.selected_match_index = Some(index); + self.awaiting = None; + self.exhausted_older = false; + self.exhausted_newer = false; + } } #[cfg(test)] @@ -783,6 +870,64 @@ mod tests { ); } + #[test] + fn search_skips_duplicate_local_matches() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("cargo test -p codex-tui".to_string())); + history.record_local_submission(HistoryEntry::new("git status".to_string())); + history.record_local_submission(HistoryEntry::new("git diff".to_string())); + + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git diff".to_string())), + history.search( + "git", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("git status".to_string())), + history.search( + "git", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + } + #[test] fn repeated_boundary_search_does_not_refetch_persistent_history() { let (tx, mut rx) = unbounded_channel::(); @@ -917,6 +1062,97 @@ mod tests { ); } + #[test] + fn search_skips_duplicate_persistent_matches() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(/*log_id*/ 1, /*entry_count*/ 4); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ true, + &tx + ) + ); + let _ = rx.try_recv().expect("expected latest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "needle same".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 3, + Some("needle same".into()), + &tx, + ) + ); + + assert_eq!( + HistorySearchResult::Pending, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + let _ = rx.try_recv().expect("expected duplicate lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 2, + Some("needle same".into()), + &tx, + ) + ); + let _ = rx.try_recv().expect("expected next lookup after duplicate"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Pending), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 1, + Some("not a match".into()), + &tx, + ) + ); + let _ = rx.try_recv().expect("expected oldest lookup"); + assert_eq!( + HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( + "needle older".to_string() + ))), + history.on_entry_response( + /*log_id*/ 1, + /*offset*/ 0, + Some("needle older".into()), + &tx, + ) + ); + assert_eq!( + HistorySearchResult::AtBoundary, + history.search( + "needle", + HistorySearchDirection::Older, + /*restart*/ false, + &tx + ) + ); + assert_eq!( + HistorySearchResult::Found(HistoryEntry::new("needle same".to_string())), + history.search( + "needle", + HistorySearchDirection::Newer, + /*restart*/ false, + &tx + ) + ); + } + #[test] fn search_is_case_insensitive_and_empty_query_finds_latest() { let (tx, _rx) = unbounded_channel::(); From 84c7b6407ca243bb8a4fe52e3c38a1cd522187ef Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 14:55:08 -0300 Subject: [PATCH 06/11] docs(tui): document history search state Explain the Ctrl+R composer and history search state machines so reviewers can follow draft restoration, async fetches, and dedupe. Keep the documentation scoped to the existing implementation without changing runtime behavior. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 8 +++ .../src/bottom_pane/chat_composer_history.rs | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3031134437a..c4d296dcf7b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -375,11 +375,19 @@ struct FooterFlash { #[derive(Clone, Debug)] struct HistorySearchSession { + /// Draft to restore when search is canceled or a query has no match. original_draft: ComposerDraft, + /// Footer-owned query text typed while Ctrl+R search is active. query: String, + /// User-visible search status used to choose footer hints and composer preview behavior. status: HistorySearchStatus, } +/// User-visible phase of the active Ctrl+R search session. +/// +/// Search keeps the footer query and the composer preview separate: `Idle` leaves the original +/// draft untouched, `Searching` waits for persistent history, `Match` previews a found entry, and +/// `NoMatch` restores the original draft while leaving the search input open for more typing. #[derive(Clone, Debug)] enum HistorySearchStatus { Idle, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 7d6f8672047..40c20e6e1a9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -1,3 +1,16 @@ +//! The chat composer history module owns shell-style recall and incremental search traversal. +//! +//! It combines persistent cross-session entries with local in-session entries into one offset +//! space. Persistent entries are fetched lazily and re-enter this state machine through +//! [`ChatComposerHistory::on_entry_response`], while local entries are already available with full +//! draft metadata. +//! +//! Ctrl+R search is modeled separately from normal Up/Down navigation because it has different +//! guarantees: query edits restart from the newest match, repeated Older/Newer keys move through +//! unique matching text, pending persistent fetches continue the same scan after the response +//! arrives, and boundary hits must not advance hidden cursor state. Search deduplication is scoped +//! to a single active search session and uses exact prompt text; it does not mutate stored history +//! or change normal history browsing. use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -115,10 +128,18 @@ pub(crate) struct ChatComposerHistory { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum HistorySearchDirection { + /// Traverse toward older history offsets. Older, + /// Traverse toward newer history offsets. Newer, } +/// Result of a single incremental history search step. +/// +/// `Pending` means a persistent entry lookup has been requested and the caller should keep the +/// visible search session open until [`ChatComposerHistory::on_entry_response`] supplies the next +/// result. `AtBoundary` means the current selected match is still valid but the requested direction +/// has no further unique match; callers should avoid treating it like a query miss. #[derive(Clone, Debug, PartialEq)] pub(crate) enum HistorySearchResult { Found(HistoryEntry), @@ -127,6 +148,10 @@ pub(crate) enum HistorySearchResult { NotFound, } +/// Result of integrating an asynchronous persistent history response. +/// +/// A response can satisfy normal Up/Down navigation, resume a pending Ctrl+R search scan, or be +/// ignored if it belongs to a stale log or an offset the composer no longer needs. #[derive(Clone, Debug, PartialEq)] pub(crate) enum HistoryEntryResponse { Found(HistoryEntry), @@ -134,6 +159,13 @@ pub(crate) enum HistoryEntryResponse { Ignored, } +/// State for one active Ctrl+R search query. +/// +/// The state keeps two cursors: `selected_offset` is the raw combined-history offset used to +/// continue scanning, while `selected_match_index` points into `unique_matches` so already +/// discovered unique results can be revisited without rescanning duplicate offsets. `seen_texts` +/// intentionally keys on exact prompt text because the UI previews and accepts text, not the +/// storage identity of each historical record. #[derive(Clone, Debug)] struct HistorySearchState { query: String, @@ -147,12 +179,22 @@ struct HistorySearchState { exhausted_newer: bool, } +/// A unique search match cached with enough draft state to be selected again. +/// +/// The vector of these matches is kept in newest-to-oldest offset order. Storing the entry beside +/// the offset avoids depending on later cache lookups when the user moves Newer/Older among matches +/// that have already been discovered. #[derive(Clone, Debug)] struct UniqueHistoryMatch { offset: usize, entry: HistoryEntry, } +/// Persistent-history lookup currently blocking an incremental search scan. +/// +/// The pending request records the direction and boundary behavior that were active when the fetch +/// was issued so the response can either return a unique match or continue scanning as if no async +/// gap had occurred. #[derive(Clone, Copy, Debug)] struct PendingHistorySearch { offset: usize, @@ -353,6 +395,14 @@ impl ChatComposerHistory { HistoryEntryResponse::Ignored } + /// Advance the active Ctrl+R search and return the next visible search state. + /// + /// Callers pass `restart` after opening search or editing the query; that clears the unique + /// match cache and starts from the end of combined history. Repeated calls with the same query + /// and `restart == false` move relative to the current unique match, preserving the selected + /// entry at boundaries. Calling this while a previous persistent lookup is still pending will + /// keep returning `Pending`; otherwise a stale response could race with a newer user action and + /// replace the composer with an unexpected entry. pub fn search( &mut self, query: &str, From 027691d2613492246229e804ba6b9871e17ccb18 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 15:11:32 -0300 Subject: [PATCH 07/11] refactor(tui): extract composer history search Move the Ctrl-R composer search session, footer rendering, and match highlight helpers into a child module so chat_composer.rs owns less feature-specific state. Keep traversal and dedupe in chat_composer_history.rs while moving the focused search tests next to the extracted implementation. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 679 +--------------- .../chat_composer/history_search.rs | 728 ++++++++++++++++++ 2 files changed, 731 insertions(+), 676 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c4d296dcf7b..f66f6605ada 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -149,8 +149,6 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::chat_composer_history::HistoryEntry; use super::chat_composer_history::HistoryEntryResponse; -use super::chat_composer_history::HistorySearchDirection; -use super::chat_composer_history::HistorySearchResult; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::command_popup::CommandPopupFlags; @@ -194,6 +192,9 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; +mod history_search; + +use self::history_search::HistorySearchSession; use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; @@ -373,29 +374,6 @@ struct FooterFlash { expires_at: Instant, } -#[derive(Clone, Debug)] -struct HistorySearchSession { - /// Draft to restore when search is canceled or a query has no match. - original_draft: ComposerDraft, - /// Footer-owned query text typed while Ctrl+R search is active. - query: String, - /// User-visible search status used to choose footer hints and composer preview behavior. - status: HistorySearchStatus, -} - -/// User-visible phase of the active Ctrl+R search session. -/// -/// Search keeps the footer query and the composer preview separate: `Idle` leaves the original -/// draft untouched, `Searching` waits for persistent history, `Match` previews a found entry, and -/// `NoMatch` restores the original draft while leaving the search input open for more typing. -#[derive(Clone, Debug)] -enum HistorySearchStatus { - Idle, - Searching, - Match, - NoMatch, -} - #[derive(Clone, Debug)] struct ComposerDraft { text: String, @@ -1355,245 +1333,6 @@ impl ChatComposer { self.history_search.is_some() || !matches!(self.active_popup, ActivePopup::None) } - #[cfg(test)] - fn history_search_active(&self) -> bool { - self.history_search.is_some() - } - - fn is_history_search_key(key_event: &KeyEvent) -> bool { - matches!( - key_event, - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r') - ) || matches!( - key_event, - KeyEvent { - code: KeyCode::Char('\u{0012}'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } - ) - } - - fn is_history_search_forward_key(key_event: &KeyEvent) -> bool { - matches!( - key_event, - KeyEvent { - code: KeyCode::Char(c), - modifiers, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s') - ) || matches!( - key_event, - KeyEvent { - code: KeyCode::Char('\u{0013}'), - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } - ) - } - - fn begin_history_search(&mut self) -> (InputResult, bool) { - if self.current_file_query.is_some() { - self.app_event_tx - .send(AppEvent::StartFileSearch(String::new())); - self.current_file_query = None; - } - self.active_popup = ActivePopup::None; - self.selected_remote_image_index = None; - self.history_search = Some(HistorySearchSession { - original_draft: self.snapshot_draft(), - query: String::new(), - status: HistorySearchStatus::Idle, - }); - self.history.reset_search(); - (InputResult::None, true) - } - - fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if key_event.kind == KeyEventKind::Release { - return (InputResult::None, false); - } - - if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) { - let result = self.history_search_in_direction(HistorySearchDirection::Older); - return (result, true); - } - - if Self::is_history_search_forward_key(&key_event) - || matches!(key_event.code, KeyCode::Down) - { - let result = self.history_search_in_direction(HistorySearchDirection::Newer); - return (result, true); - } - - match key_event { - KeyEvent { - code: KeyCode::Esc, .. - } => { - self.cancel_history_search(); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - } => { - if self - .history_search - .as_ref() - .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) - { - self.history_search = None; - self.history.reset_search(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.move_cursor_to_end(); - } - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Backspace, - .. - } - | KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - if let Some(search) = self.history_search.as_ref() { - let mut query = search.query.clone(); - query.pop(); - self.update_history_search_query(query); - } - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Char('u'), - modifiers: KeyModifiers::CONTROL, - .. - } => { - self.update_history_search_query(String::new()); - (InputResult::None, true) - } - KeyEvent { - code: KeyCode::Char(ch), - modifiers, - .. - } if !has_ctrl_or_alt(modifiers) => { - if let Some(search) = self.history_search.as_ref() { - let mut query = search.query.clone(); - query.push(ch); - self.update_history_search_query(query); - } - (InputResult::None, true) - } - _ => (InputResult::None, true), - } - } - - fn history_search_in_direction(&mut self, direction: HistorySearchDirection) -> InputResult { - let Some((query, original_draft)) = self - .history_search - .as_ref() - .map(|search| (search.query.clone(), search.original_draft.clone())) - else { - return InputResult::None; - }; - if query.is_empty() { - self.history.reset_search(); - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::Idle; - } - self.restore_draft(original_draft); - return InputResult::None; - } - let result = self.history.search( - &query, - direction, - /*restart*/ false, - &self.app_event_tx, - ); - self.apply_history_search_result(result); - InputResult::None - } - - fn update_history_search_query(&mut self, query: String) { - let Some(original_draft) = self - .history_search - .as_ref() - .map(|search| search.original_draft.clone()) - else { - return; - }; - if let Some(search) = self.history_search.as_mut() { - search.query = query.clone(); - search.status = HistorySearchStatus::Searching; - } - self.restore_draft(original_draft); - if query.is_empty() { - self.history.reset_search(); - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::Idle; - } - return; - } - let result = self.history.search( - &query, - HistorySearchDirection::Older, - /*restart*/ true, - &self.app_event_tx, - ); - self.apply_history_search_result(result); - } - - fn cancel_history_search(&mut self) { - if let Some(search) = self.history_search.take() { - self.history.reset_search(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.restore_draft(search.original_draft); - } - } - - fn apply_history_search_result(&mut self, result: HistorySearchResult) { - match result { - HistorySearchResult::Found(entry) => { - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::Match; - } - self.apply_history_entry(entry); - } - HistorySearchResult::Pending => { - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::Searching; - } - } - HistorySearchResult::AtBoundary => { - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::Match; - } - } - HistorySearchResult::NotFound => { - let original_draft = self - .history_search - .as_ref() - .map(|search| search.original_draft.clone()); - if let Some(search) = self.history_search.as_mut() { - search.status = HistorySearchStatus::NoMatch; - } - if let Some(original_draft) = original_draft { - self.restore_draft(original_draft); - } - } - } - } - /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -3236,130 +2975,6 @@ impl ChatComposer { } } - fn history_search_footer_line(&self) -> Option> { - let search = self.history_search.as_ref()?; - let mut line = Line::from(vec![ - "reverse-i-search: ".dim(), - search.query.clone().cyan(), - ]); - match search.status { - HistorySearchStatus::Idle => {} - HistorySearchStatus::Searching => line.push_span(" searching".dim()), - HistorySearchStatus::Match => { - line.push_span(" ".dim()); - line.push_span(Self::history_search_action_key_span(KeyCode::Enter)); - line.push_span(" accept".dim()); - line.push_span(" · ".dim()); - line.push_span(Self::history_search_action_key_span(KeyCode::Esc)); - line.push_span(" cancel".dim()); - } - HistorySearchStatus::NoMatch => line.push_span(" no match".red()), - } - Some(line) - } - - fn history_search_action_key_span(key: KeyCode) -> Span<'static> { - Span::from(key_hint::plain(key)).cyan().bold().not_dim() - } - - fn history_search_highlight_ranges(&self) -> Vec> { - let Some(search) = self.history_search.as_ref() else { - return Vec::new(); - }; - if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { - return Vec::new(); - } - Self::case_insensitive_match_ranges(self.textarea.text(), &search.query) - } - - fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { - if query.is_empty() { - return Vec::new(); - } - - let query_lower = query - .chars() - .flat_map(char::to_lowercase) - .collect::(); - if query_lower.is_empty() { - return Vec::new(); - } - - let mut folded = String::new(); - let mut folded_spans: Vec<(Range, Range)> = Vec::new(); - for (original_start, ch) in text.char_indices() { - let original_range = original_start..original_start + ch.len_utf8(); - for lower in ch.to_lowercase() { - let folded_start = folded.len(); - folded.push(lower); - folded_spans.push((folded_start..folded.len(), original_range.clone())); - } - } - - let mut ranges = Vec::new(); - let mut search_from = 0; - while search_from <= folded.len() - && let Some(relative_start) = folded[search_from..].find(&query_lower) - { - let folded_start = search_from + relative_start; - let folded_end = folded_start + query_lower.len(); - if let Some((_, first_original)) = folded_spans.iter().find(|(folded_range, _)| { - folded_range.end > folded_start && folded_range.start < folded_end - }) { - let original_end = folded_spans - .iter() - .rev() - .find(|(folded_range, _)| { - folded_range.end > folded_start && folded_range.start < folded_end - }) - .map(|(_, original_range)| original_range.end) - .unwrap_or(first_original.end); - ranges.push(first_original.start..original_end); - } - search_from = folded_end; - } - ranges - } - - fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let search = self.history_search.as_ref()?; - let [_, _, _, popup_rect] = self.layout_areas(area); - if popup_rect.is_empty() { - return None; - } - - let footer_props = self.footer_props(); - let footer_hint_height = self - .custom_footer_height() - .unwrap_or_else(|| footer_height(&footer_props)); - let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if hint_rect.is_empty() { - return None; - } - - let prompt_width = Line::from("reverse-i-search: ").width() as u16; - let query_width = Line::from(search.query.clone()).width() as u16; - let desired_x = hint_rect - .x - .saturating_add(FOOTER_INDENT_COLS as u16) - .saturating_add(prompt_width) - .saturating_add(query_width); - let max_x = hint_rect - .x - .saturating_add(hint_rect.width.saturating_sub(1)); - Some((desired_x.min(max_x), hint_rect.y)) - } - /// Resolve the effective footer mode via a small priority waterfall. /// /// The base mode is derived solely from whether the composer is empty: @@ -7684,294 +7299,6 @@ mod tests { assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); } - #[test] - fn history_search_opens_without_previewing_latest_entry() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("remembered command".to_string())); - composer.set_text_content(String::new(), Vec::new(), Vec::new()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - - assert!(composer.history_search_active()); - assert!(composer.textarea.is_empty()); - assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); - } - - #[test] - fn history_search_match_ranges_are_case_insensitive() { - assert_eq!( - ChatComposer::case_insensitive_match_ranges("git status git", "GIT"), - vec![0..3, 11..14] - ); - assert_eq!( - ChatComposer::case_insensitive_match_ranges("aİ i", "i"), - vec![1..3, 4..5] - ); - assert!(ChatComposer::case_insensitive_match_ranges("git", "").is_empty()); - } - - #[test] - fn history_search_accepts_matching_entry() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("git status".to_string())); - composer - .history - .record_local_submission(HistoryEntry::new("cargo test".to_string())); - composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - - for ch in ['g', 'i', 't'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "git status"); - assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); - } - - #[test] - fn history_search_stays_on_single_match_at_boundaries() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer.history.record_local_submission(HistoryEntry::new( - "Find and fix a bug in @filename".to_string(), - )); - composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - for ch in ['b', 'u', 'g'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); - - for _ in 0..3 { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); - assert!( - composer - .history_search - .as_ref() - .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) - ); - - for _ in 0..3 { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - } - assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); - assert!( - composer - .history_search - .as_ref() - .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) - ); - } - - #[test] - fn history_search_footer_action_hints_are_emphasized() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ true, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("cargo test".to_string())); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - - let line = composer - .history_search_footer_line() - .expect("expected history search footer line"); - assert_eq!( - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::>(), - vec![ - "reverse-i-search: ", - "c", - " ", - "enter", - " accept", - " · ", - "esc", - " cancel" - ] - ); - - let query_style = line.spans[1].style; - assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan)); - - let enter_style = line.spans[3].style; - assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan)); - assert!(enter_style.add_modifier.contains(Modifier::BOLD)); - assert!(enter_style.sub_modifier.contains(Modifier::DIM)); - - let accept_style = line.spans[4].style; - assert!(accept_style.add_modifier.contains(Modifier::DIM)); - - let separator_style = line.spans[5].style; - assert!(separator_style.add_modifier.contains(Modifier::DIM)); - - let esc_style = line.spans[6].style; - assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan)); - assert!(esc_style.add_modifier.contains(Modifier::BOLD)); - assert!(esc_style.sub_modifier.contains(Modifier::DIM)); - - let cancel_style = line.spans[7].style; - assert!(cancel_style.add_modifier.contains(Modifier::DIM)); - } - - #[test] - fn history_search_highlights_matches_until_accepted() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ true, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("cargo test".to_string())); - composer - .history - .record_local_submission(HistoryEntry::new("git status".to_string())); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - for ch in ['g', 'i', 't'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - let area = Rect::new(0, 0, 60, 8); - let [_, _, textarea_rect, _] = composer.layout_areas(area); - let mut buf = Buffer::empty(area); - composer.render(area, &mut buf); - let x = textarea_rect.x; - let y = textarea_rect.y; - assert_eq!(buf[(x, y)].symbol(), "g"); - for offset in 0..3 { - let modifier = buf[(x + offset, y)].style().add_modifier; - assert!(modifier.contains(Modifier::REVERSED)); - assert!(modifier.contains(Modifier::BOLD)); - } - assert!( - !buf[(x + 3, y)] - .style() - .add_modifier - .contains(Modifier::REVERSED) - ); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area); - let mut accepted_buf = Buffer::empty(area); - composer.render(area, &mut accepted_buf); - for offset in 0..3 { - let modifier = accepted_buf - [(accepted_textarea_rect.x + offset, accepted_textarea_rect.y)] - .style() - .add_modifier; - assert!(!modifier.contains(Modifier::REVERSED)); - assert!(!modifier.contains(Modifier::BOLD)); - } - } - - #[test] - fn history_search_esc_restores_original_draft() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("remembered command".to_string())); - composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - composer.textarea.set_cursor(/*pos*/ 2); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - assert_eq!(composer.textarea.text(), "draft"); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); - assert_eq!(composer.textarea.text(), "remembered command"); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.textarea.cursor(), 2); - } - - #[test] - fn history_search_no_match_restores_preview_but_keeps_search_open() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - /*has_input_focus*/ true, - sender, - /*enhanced_keys_supported*/ false, - "Ask Codex to do anything".to_string(), - /*disable_paste_burst*/ false, - ); - composer - .history - .record_local_submission(HistoryEntry::new("git status".to_string())); - composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); - - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); - for ch in ['z', 'z', 'z'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - assert!(composer.history_search_active()); - assert_eq!(composer.textarea.text(), "draft"); - assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); - } - #[test] fn set_text_content_reattaches_images_without_placeholder_metadata() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs new file mode 100644 index 00000000000..cec29adfa64 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -0,0 +1,728 @@ +//! Composer-side Ctrl+R reverse history search state and rendering helpers. +//! +//! The persistent and local history stores live in `chat_composer_history`, but the composer owns +//! the active search session because it has to snapshot/restore the editable draft, preview matches +//! in the textarea, and render the footer prompt while the footer line is acting as the search +//! input. + +use std::ops::Range; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; + +use super::super::chat_composer_history::HistorySearchDirection; +use super::super::chat_composer_history::HistorySearchResult; +use super::super::footer::footer_height; +use super::super::footer::reset_mode_after_activity; +use super::ActivePopup; +use super::ChatComposer; +use super::ComposerDraft; +use super::InputResult; +use crate::app_event::AppEvent; +use crate::key_hint; +use crate::key_hint::has_ctrl_or_alt; +use crate::ui_consts::FOOTER_INDENT_COLS; + +#[derive(Clone, Debug)] +pub(super) struct HistorySearchSession { + /// Draft to restore when search is canceled or a query has no match. + original_draft: ComposerDraft, + /// Footer-owned query text typed while Ctrl+R search is active. + query: String, + /// User-visible search status used to choose footer hints and composer preview behavior. + status: HistorySearchStatus, +} + +/// User-visible phase of the active Ctrl+R search session. +/// +/// Search keeps the footer query and the composer preview separate: `Idle` leaves the original +/// draft untouched, `Searching` waits for persistent history, `Match` previews a found entry, and +/// `NoMatch` restores the original draft while leaving the search input open for more typing. +#[derive(Clone, Debug)] +enum HistorySearchStatus { + Idle, + Searching, + Match, + NoMatch, +} + +impl ChatComposer { + #[cfg(test)] + pub(super) fn history_search_active(&self) -> bool { + self.history_search.is_some() + } + + pub(super) fn is_history_search_key(key_event: &KeyEvent) -> bool { + matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'r') + ) || matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{0012}'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + } + + fn is_history_search_forward_key(key_event: &KeyEvent) -> bool { + matches!( + key_event, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'s') + ) || matches!( + key_event, + KeyEvent { + code: KeyCode::Char('\u{0013}'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } + ) + } + + pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { + if self.current_file_query.is_some() { + self.app_event_tx + .send(AppEvent::StartFileSearch(String::new())); + self.current_file_query = None; + } + self.active_popup = ActivePopup::None; + self.selected_remote_image_index = None; + self.history_search = Some(HistorySearchSession { + original_draft: self.snapshot_draft(), + query: String::new(), + status: HistorySearchStatus::Idle, + }); + self.history.reset_search(); + (InputResult::None, true) + } + + pub(super) fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if key_event.kind == KeyEventKind::Release { + return (InputResult::None, false); + } + + if Self::is_history_search_key(&key_event) || matches!(key_event.code, KeyCode::Up) { + let result = self.history_search_in_direction(HistorySearchDirection::Older); + return (result, true); + } + + if Self::is_history_search_forward_key(&key_event) + || matches!(key_event.code, KeyCode::Down) + { + let result = self.history_search_in_direction(HistorySearchDirection::Newer); + return (result, true); + } + + match key_event { + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if self + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + { + self.history_search = None; + self.history.reset_search(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.move_cursor_to_end(); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Backspace, + .. + } + | KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + if let Some(search) = self.history_search.as_ref() { + let mut query = search.query.clone(); + query.pop(); + self.update_history_search_query(query); + } + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('u'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.update_history_search_query(String::new()); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } if !has_ctrl_or_alt(modifiers) => { + if let Some(search) = self.history_search.as_ref() { + let mut query = search.query.clone(); + query.push(ch); + self.update_history_search_query(query); + } + (InputResult::None, true) + } + _ => (InputResult::None, true), + } + } + + fn history_search_in_direction(&mut self, direction: HistorySearchDirection) -> InputResult { + let Some((query, original_draft)) = self + .history_search + .as_ref() + .map(|search| (search.query.clone(), search.original_draft.clone())) + else { + return InputResult::None; + }; + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + self.restore_draft(original_draft); + return InputResult::None; + } + let result = self.history.search( + &query, + direction, + /*restart*/ false, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + InputResult::None + } + + fn update_history_search_query(&mut self, query: String) { + let Some(original_draft) = self + .history_search + .as_ref() + .map(|search| search.original_draft.clone()) + else { + return; + }; + if let Some(search) = self.history_search.as_mut() { + search.query = query.clone(); + search.status = HistorySearchStatus::Searching; + } + self.restore_draft(original_draft); + if query.is_empty() { + self.history.reset_search(); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Idle; + } + return; + } + let result = self.history.search( + &query, + HistorySearchDirection::Older, + /*restart*/ true, + &self.app_event_tx, + ); + self.apply_history_search_result(result); + } + + fn cancel_history_search(&mut self) { + if let Some(search) = self.history_search.take() { + self.history.reset_search(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.restore_draft(search.original_draft); + } + } + + pub(super) fn apply_history_search_result(&mut self, result: HistorySearchResult) { + match result { + HistorySearchResult::Found(entry) => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + self.apply_history_entry(entry); + } + HistorySearchResult::Pending => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Searching; + } + } + HistorySearchResult::AtBoundary => { + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::Match; + } + } + HistorySearchResult::NotFound => { + let original_draft = self + .history_search + .as_ref() + .map(|search| search.original_draft.clone()); + if let Some(search) = self.history_search.as_mut() { + search.status = HistorySearchStatus::NoMatch; + } + if let Some(original_draft) = original_draft { + self.restore_draft(original_draft); + } + } + } + } + + pub(super) fn history_search_footer_line(&self) -> Option> { + let search = self.history_search.as_ref()?; + let mut line = Line::from(vec![ + "reverse-i-search: ".dim(), + search.query.clone().cyan(), + ]); + match search.status { + HistorySearchStatus::Idle => {} + HistorySearchStatus::Searching => line.push_span(" searching".dim()), + HistorySearchStatus::Match => { + line.push_span(" ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Enter)); + line.push_span(" accept".dim()); + line.push_span(" · ".dim()); + line.push_span(Self::history_search_action_key_span(KeyCode::Esc)); + line.push_span(" cancel".dim()); + } + HistorySearchStatus::NoMatch => line.push_span(" no match".red()), + } + Some(line) + } + + fn history_search_action_key_span(key: KeyCode) -> Span<'static> { + Span::from(key_hint::plain(key)).cyan().bold().not_dim() + } + + pub(super) fn history_search_highlight_ranges(&self) -> Vec> { + let Some(search) = self.history_search.as_ref() else { + return Vec::new(); + }; + if !matches!(search.status, HistorySearchStatus::Match) || search.query.is_empty() { + return Vec::new(); + } + Self::case_insensitive_match_ranges(self.textarea.text(), &search.query) + } + + fn case_insensitive_match_ranges(text: &str, query: &str) -> Vec> { + if query.is_empty() { + return Vec::new(); + } + + let query_lower = query + .chars() + .flat_map(char::to_lowercase) + .collect::(); + if query_lower.is_empty() { + return Vec::new(); + } + + let mut folded = String::new(); + let mut folded_spans: Vec<(Range, Range)> = Vec::new(); + for (original_start, ch) in text.char_indices() { + let original_range = original_start..original_start + ch.len_utf8(); + for lower in ch.to_lowercase() { + let folded_start = folded.len(); + folded.push(lower); + folded_spans.push((folded_start..folded.len(), original_range.clone())); + } + } + + let mut ranges = Vec::new(); + let mut search_from = 0; + while search_from <= folded.len() + && let Some(relative_start) = folded[search_from..].find(&query_lower) + { + let folded_start = search_from + relative_start; + let folded_end = folded_start + query_lower.len(); + if let Some((_, first_original)) = folded_spans.iter().find(|(folded_range, _)| { + folded_range.end > folded_start && folded_range.start < folded_end + }) { + let original_end = folded_spans + .iter() + .rev() + .find(|(folded_range, _)| { + folded_range.end > folded_start && folded_range.start < folded_end + }) + .map(|(_, original_range)| original_range.end) + .unwrap_or(first_original.end); + ranges.push(first_original.start..original_end); + } + search_from = folded_end; + } + ranges + } + + pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let search = self.history_search.as_ref()?; + let [_, _, _, popup_rect] = self.layout_areas(area); + if popup_rect.is_empty() { + return None; + } + + let footer_props = self.footer_props(); + let footer_hint_height = self + .custom_footer_height() + .unwrap_or_else(|| footer_height(&footer_props)); + let footer_spacing = Self::footer_spacing(footer_hint_height); + let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + }; + if hint_rect.is_empty() { + return None; + } + + let prompt_width = Line::from("reverse-i-search: ").width() as u16; + let query_width = Line::from(search.query.clone()).width() as u16; + let desired_x = hint_rect + .x + .saturating_add(FOOTER_INDENT_COLS as u16) + .saturating_add(prompt_width) + .saturating_add(query_width); + let max_x = hint_rect + .x + .saturating_add(hint_rect.width.saturating_sub(1)); + Some((desired_x.min(max_x), hint_rect.y)) + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Modifier; + use tokio::sync::mpsc::unbounded_channel; + + use super::super::super::chat_composer_history::HistoryEntry; + use super::super::super::footer::FooterMode; + use super::super::ChatComposer; + use super::HistorySearchStatus; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::render::renderable::Renderable; + + #[test] + fn history_search_opens_without_previewing_latest_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(composer.textarea.is_empty()); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } + + #[test] + fn history_search_match_ranges_are_case_insensitive() { + assert_eq!( + ChatComposer::case_insensitive_match_ranges("git status git", "GIT"), + vec![0..3, 11..14] + ); + assert_eq!( + ChatComposer::case_insensitive_match_ranges("aİ i", "i"), + vec![1..3, 4..5] + ); + assert!(ChatComposer::case_insensitive_match_ranges("git", "").is_empty()); + } + + #[test] + fn history_search_accepts_matching_entry() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "git status"); + assert_eq!(composer.textarea.cursor(), composer.textarea.text().len()); + } + + #[test] + fn history_search_stays_on_single_match_at_boundaries() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.history.record_local_submission(HistoryEntry::new( + "Find and fix a bug in @filename".to_string(), + )); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['b', 'u', 'g'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + + for _ in 0..3 { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + + for _ in 0..3 { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "Find and fix a bug in @filename"); + assert!( + composer + .history_search + .as_ref() + .is_some_and(|search| matches!(search.status, HistorySearchStatus::Match)) + ); + } + + #[test] + fn history_search_footer_action_hints_are_emphasized() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + + let line = composer + .history_search_footer_line() + .expect("expected history search footer line"); + assert_eq!( + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::>(), + vec![ + "reverse-i-search: ", + "c", + " ", + "enter", + " accept", + " · ", + "esc", + " cancel" + ] + ); + + let query_style = line.spans[1].style; + assert_eq!(query_style.fg, Some(ratatui::style::Color::Cyan)); + + let enter_style = line.spans[3].style; + assert_eq!(enter_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(enter_style.add_modifier.contains(Modifier::BOLD)); + assert!(enter_style.sub_modifier.contains(Modifier::DIM)); + + let accept_style = line.spans[4].style; + assert!(accept_style.add_modifier.contains(Modifier::DIM)); + + let separator_style = line.spans[5].style; + assert!(separator_style.add_modifier.contains(Modifier::DIM)); + + let esc_style = line.spans[6].style; + assert_eq!(esc_style.fg, Some(ratatui::style::Color::Cyan)); + assert!(esc_style.add_modifier.contains(Modifier::BOLD)); + assert!(esc_style.sub_modifier.contains(Modifier::DIM)); + + let cancel_style = line.spans[7].style; + assert!(cancel_style.add_modifier.contains(Modifier::DIM)); + } + + #[test] + fn history_search_highlights_matches_until_accepted() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("cargo test".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['g', 'i', 't'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + let area = Rect::new(0, 0, 60, 8); + let [_, _, textarea_rect, _] = composer.layout_areas(area); + let mut buf = Buffer::empty(area); + composer.render(area, &mut buf); + let x = textarea_rect.x; + let y = textarea_rect.y; + assert_eq!(buf[(x, y)].symbol(), "g"); + for offset in 0..3 { + let modifier = buf[(x + offset, y)].style().add_modifier; + assert!(modifier.contains(Modifier::REVERSED)); + assert!(modifier.contains(Modifier::BOLD)); + } + assert!( + !buf[(x + 3, y)] + .style() + .add_modifier + .contains(Modifier::REVERSED) + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let [_, _, accepted_textarea_rect, _] = composer.layout_areas(area); + let mut accepted_buf = Buffer::empty(area); + composer.render(area, &mut accepted_buf); + for offset in 0..3 { + let modifier = accepted_buf + [(accepted_textarea_rect.x + offset, accepted_textarea_rect.y)] + .style() + .add_modifier; + assert!(!modifier.contains(Modifier::REVERSED)); + assert!(!modifier.contains(Modifier::BOLD)); + } + } + + #[test] + fn history_search_esc_restores_original_draft() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(/*pos*/ 2); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(composer.textarea.text(), "draft"); + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "remembered command"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.textarea.cursor(), 2); + } + + #[test] + fn history_search_no_match_restores_preview_but_keeps_search_open() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("git status".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['z', 'z', 'z'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + + assert!(composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.footer_mode(), FooterMode::HistorySearch); + } +} From 597749b16a08d94d6a85942e422e2db8e1586e24 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 15:16:22 -0300 Subject: [PATCH 08/11] docs(tui): clarify history search ownership Document the split between composer-owned Ctrl-R search UI state and history-owned traversal state so reviewers can follow the lifecycle and async response contracts. Add the reverse-search mental model to docs/tui-chat-composer.md without changing runtime behavior. --- .../chat_composer/history_search.rs | 61 +++++++++++++++++++ .../src/bottom_pane/chat_composer_history.rs | 55 ++++++++++++++--- docs/tui-chat-composer.md | 8 +++ 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index cec29adfa64..f9f54353819 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -4,6 +4,19 @@ //! the active search session because it has to snapshot/restore the editable draft, preview matches //! in the textarea, and render the footer prompt while the footer line is acting as the search //! input. +//! +//! This module is responsible for the UI-facing lifecycle of a search session: recognizing the +//! keys that enter and drive search mode, keeping the footer query separate from the textarea +//! preview, restoring the original draft on cancellation or misses, and translating history search +//! results into composer-visible state. It deliberately does not decide which history entries +//! match, how duplicate results are skipped, or when persistent history should be fetched; those +//! traversal invariants stay with `ChatComposerHistory`. +//! +//! A search session starts idle with an empty footer query, so opening Ctrl+R never previews the +//! latest history entry by itself. Typing a query restarts traversal from newest to oldest, +//! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current +//! preview as an editable draft, and `Esc` restores the exact draft that existed before search +//! started. use std::ops::Range; @@ -31,6 +44,11 @@ use crate::key_hint; use crate::key_hint::has_ctrl_or_alt; use crate::ui_consts::FOOTER_INDENT_COLS; +/// Active composer-owned state for one Ctrl+R search interaction. +/// +/// The session is created only by [`ChatComposer::begin_history_search`] and is cleared only by +/// accepting, canceling, or replacing the search mode. It stores the original draft separately from +/// the footer query so transient previews never destroy the user's in-progress composer content. #[derive(Clone, Debug)] pub(super) struct HistorySearchSession { /// Draft to restore when search is canceled or a query has no match. @@ -60,6 +78,12 @@ impl ChatComposer { self.history_search.is_some() } + /// Returns whether a key event should open reverse history search or step to an older match. + /// + /// The check accepts both normal Ctrl+R reports and the raw control character variant that + /// some terminals emit. Callers should only use this before generic text handling; treating the + /// raw control character as ordinary input would insert an invisible byte into the search query + /// or composer draft. pub(super) fn is_history_search_key(key_event: &KeyEvent) -> bool { matches!( key_event, @@ -100,6 +124,12 @@ impl ChatComposer { ) } + /// Opens footer-owned reverse history search without previewing history yet. + /// + /// Entering search mode snapshots the full composer draft, clears any file/search popup state, + /// and resets history traversal. The first visible match is produced only after the footer + /// query becomes non-empty, which keeps Ctrl+R from replacing an empty composer with the latest + /// prompt before the user has searched for anything. pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { if self.current_file_query.is_some() { self.app_event_tx @@ -117,6 +147,14 @@ impl ChatComposer { (InputResult::None, true) } + /// Handles every key while the footer is acting as the history search input. + /// + /// The method consumes search-mode keys before normal composer editing sees them. It guarantees + /// that `Esc` restores the original draft, `Enter` only accepts an actual match, plain + /// characters edit the footer query, and navigation keys delegate traversal to + /// `ChatComposerHistory`. Calling this when no search session exists is harmless for ignored + /// keys but would make query-edit branches no-op, so route here only after + /// `history_search.is_some()` has been established. pub(super) fn handle_history_search_key(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if key_event.kind == KeyEventKind::Release { return (InputResult::None, false); @@ -261,6 +299,13 @@ impl ChatComposer { } } + /// Applies a traversal result to the composer preview and search status. + /// + /// `Found` previews the matching entry, `Pending` keeps the footer in a waiting state while an + /// async persistent entry lookup is outstanding, `AtBoundary` preserves the current match, and + /// `NotFound` restores the original draft while keeping the query available for further edits. + /// Treating `AtBoundary` like `NotFound` would produce the visible "no match" flicker at the + /// end of a one-result search and desynchronize Up/Down counts. pub(super) fn apply_history_search_result(&mut self, result: HistorySearchResult) { match result { HistorySearchResult::Found(entry) => { @@ -294,6 +339,12 @@ impl ChatComposer { } } + /// Builds the footer line shown while reverse history search is active. + /// + /// The footer displays the query as the editable field and uses the status to decide whether + /// to show searching, match actions, or no-match feedback. The line is intentionally separate + /// from cursor placement so rendering can fall back to normal footer layout if a small terminal + /// cannot allocate a distinct hint row. pub(super) fn history_search_footer_line(&self) -> Option> { let search = self.history_search.as_ref()?; let mut line = Line::from(vec![ @@ -320,6 +371,11 @@ impl ChatComposer { Span::from(key_hint::plain(key)).cyan().bold().not_dim() } + /// Returns byte ranges that should be highlighted in the current composer preview. + /// + /// Highlights are only exposed while a matched history entry is being previewed. Once the user + /// accepts with `Enter`, the search session is cleared and this returns an empty set so the + /// accepted text becomes an ordinary editable draft again. pub(super) fn history_search_highlight_ranges(&self) -> Vec> { let Some(search) = self.history_search.as_ref() else { return Vec::new(); @@ -379,6 +435,11 @@ impl ChatComposer { ranges } + /// Returns the screen cursor position for the footer query when search mode is active. + /// + /// The cursor tracks the end of the footer query rather than the textarea preview. If the + /// footer area is collapsed or too narrow, the x coordinate is clamped inside the hint rect so + /// terminal backends do not receive an off-screen cursor position. pub(super) fn history_search_cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { let search = self.history_search.as_ref()?; let [_, _, _, popup_rect] = self.layout_areas(area); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 40c20e6e1a9..e2b5971123a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -40,6 +40,12 @@ pub(crate) struct HistoryEntry { } impl HistoryEntry { + /// Creates a text-only history entry and decodes persisted mention bindings. + /// + /// Persistent history does not store attachment payloads or text-element metadata, so this + /// constructor intentionally leaves those fields empty. Local in-session submissions should be + /// recorded with the full `HistoryEntry` value built by the composer; using `new` for a local + /// image or paste submission would make recall lose placeholder ownership. pub(crate) fn new(text: String) -> Self { let decoded = decode_history_mentions(&text); Self { @@ -203,6 +209,11 @@ struct PendingHistorySearch { } impl ChatComposerHistory { + /// Creates an empty history state machine with no persistent metadata. + /// + /// The caller must provide session metadata before cross-session history can be fetched, but + /// local in-session entries can still be recorded and traversed. Keeping construction cheap and + /// metadata-free lets the composer reset and reuse this helper across session lifecycles. pub fn new() -> Self { Self { history_log_id: None, @@ -215,7 +226,11 @@ impl ChatComposerHistory { } } - /// Update metadata when a new session is configured. + /// Updates persistent history metadata when a new session is configured. + /// + /// This clears fetched entries, local entries, navigation cursors, and active search state + /// because offsets only make sense within one history log snapshot. Reusing old offsets after a + /// log-id change would allow a stale async response to hydrate the wrong prompt. pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { self.history_log_id = Some(log_id); self.history_entry_count = entry_count; @@ -226,8 +241,10 @@ impl ChatComposerHistory { self.search = None; } - /// Record a message submitted by the user in the current session so it can - /// be recalled later. + /// Records a current-session submission so it can be recalled with full draft metadata. + /// + /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or + /// search state is reset because a new newest entry changes the combined history offset space. pub fn record_local_submission(&mut self, entry: HistoryEntry) { if entry.text.is_empty() && entry.text_elements.is_empty() @@ -250,13 +267,22 @@ impl ChatComposerHistory { self.local_history.push(entry); } - /// Reset navigation tracking so the next Up key resumes from the latest entry. + /// Resets normal history navigation so the next Up key resumes from the newest entry. + /// + /// This also clears any active incremental search, since normal browsing and Ctrl+R search + /// maintain different cursor semantics. Failing to clear search here would let an old query + /// influence later Up/Down recall. pub fn reset_navigation(&mut self) { self.history_cursor = None; self.last_history_text = None; self.search = None; } + /// Clears only the active incremental search state. + /// + /// The normal Up/Down navigation cursor and cached persistent entries are left intact. Composer + /// search mode calls this when it accepts, cancels, or returns to an empty query so the next + /// search starts with a fresh unique-result cache. pub fn reset_search(&mut self) { self.search = None; } @@ -291,8 +317,11 @@ impl ChatComposerHistory { matches!(&self.last_history_text, Some(prev) if prev == text) } - /// Handle . Returns true when the key was consumed and the caller - /// should request a redraw. + /// Handles Up by moving toward older entries in the combined history space. + /// + /// Local entries can be returned immediately, while missing persistent entries emit a + /// `GetHistoryEntryRequest` and return `None` until the response arrives. Calling this while + /// Ctrl+R search is active intentionally exits search traversal. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; let total_entries = self.history_entry_count + self.local_history.len(); @@ -310,7 +339,11 @@ impl ChatComposerHistory { self.populate_history_at_index(next_idx as usize, app_event_tx) } - /// Handle . + /// Handles Down by moving toward newer entries or clearing the composer past the newest entry. + /// + /// Returning an empty `HistoryEntry` means the user moved past the newest known entry and the + /// caller should clear the composer draft. As with Up, invoking this during Ctrl+R search clears + /// search state and resumes normal shell-style browsing. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; let total_entries = self.history_entry_count + self.local_history.len(); @@ -338,7 +371,13 @@ impl ChatComposerHistory { } } - /// Integrate a GetHistoryEntryResponse event. + /// Integrates a persistent history entry response into navigation or active search. + /// + /// Responses with a stale log id are ignored, matching responses update the persistent cache, + /// and pending Ctrl+R searches resume their scan from the returned offset. The caller should + /// route `HistoryEntryResponse::Search` back to the composer search session rather than normal + /// history recall; otherwise an async search hit could be accepted without updating footer + /// status or match highlighting. pub fn on_entry_response( &mut self, log_id: u64, diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index a61a0b5bebf..3a5d2fa0b19 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -65,6 +65,14 @@ Up/Down recall is handled by `ChatComposerHistory` and merges two sources: This distinction keeps the on-disk history backward compatible and avoids persisting attachments, while still providing a richer recall experience for in-session edits. +### Reverse history search (Ctrl+R) + +Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. `Enter` accepts the preview as a normal editable draft, and `Esc` restores the exact draft that existed before search started. + +The composer owns the search session because it controls draft snapshots, footer rendering, cursor placement, and preview highlighting. `ChatComposerHistory` owns traversal: it scans persistent and local entries in one offset space, skips duplicate prompt text within a search session, keeps boundary hits on the current match, and resumes scans after asynchronous persistent history responses. + +The search query and composer text intentionally remain separate. A no-match result restores the original draft while leaving the footer query open for more typing, and accepting a match clears the search session so highlight styling disappears from the now-editable composer text. + ## Config gating for reuse `ChatComposer` now supports feature gating via `ChatComposerConfig` From 05cd3355ce6f2aca3cee4cc3bc90c90197f12965 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 15:27:00 -0300 Subject: [PATCH 09/11] fix(tui): reset navigation on search cancel Clear the normal history cursor when Esc cancels a Ctrl-R search so a previewed match cannot leak into later Up/Down navigation. Add a regression test that cancels a matched search from an empty draft and verifies the next Up starts from the newest history entry. --- .../chat_composer/history_search.rs | 35 ++++++++++++++++++- .../src/bottom_pane/chat_composer_history.rs | 2 +- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index f9f54353819..458ad41eda8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -293,7 +293,7 @@ impl ChatComposer { fn cancel_history_search(&mut self) { if let Some(search) = self.history_search.take() { - self.history.reset_search(); + self.history.reset_navigation(); self.footer_mode = reset_mode_after_activity(self.footer_mode); self.restore_draft(search.original_draft); } @@ -761,6 +761,39 @@ mod tests { assert_eq!(composer.textarea.cursor(), 2); } + #[test] + fn history_search_esc_resets_normal_history_navigation() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("oldest matching entry".to_string())); + composer + .history + .record_local_submission(HistoryEntry::new("newest entry".to_string())); + composer.set_text_content(String::new(), Vec::new(), Vec::new()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + for ch in ['m', 'a', 't', 'c', 'h'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(composer.textarea.text(), "oldest matching entry"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!composer.history_search_active()); + assert!(composer.textarea.is_empty()); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "newest entry"); + } + #[test] fn history_search_no_match_restores_preview_but_keeps_search_open() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index e2b5971123a..22f4a16a646 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -281,7 +281,7 @@ impl ChatComposerHistory { /// Clears only the active incremental search state. /// /// The normal Up/Down navigation cursor and cached persistent entries are left intact. Composer - /// search mode calls this when it accepts, cancels, or returns to an empty query so the next + /// search mode calls this when it accepts a match or returns to an empty query so the next /// search starts with a fresh unique-result cache. pub fn reset_search(&mut self) { self.search = None; From 5d8ce6f2a10d480c5690edafad6913280d9e7b56 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 16:05:17 -0300 Subject: [PATCH 10/11] fix(tui): cancel history search with ctrl-c Treat Ctrl+C as a search-mode cancellation before the bottom pane falls back to clearing drafts or triggering global interrupt handling. Restore the original draft through the shared history-search cancel path and cover both bottom-pane Ctrl+C routing and direct composer Ctrl+C key events. --- .../chat_composer/history_search.rs | 82 +++++++++++++++++-- codex-rs/tui/src/bottom_pane/mod.rs | 37 ++++++++- docs/tui-chat-composer.md | 2 +- 3 files changed, 107 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 458ad41eda8..625bcb37356 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -15,8 +15,8 @@ //! A search session starts idle with an empty footer query, so opening Ctrl+R never previews the //! latest history entry by itself. Typing a query restarts traversal from newest to oldest, //! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current -//! preview as an editable draft, and `Esc` restores the exact draft that existed before search -//! started. +//! preview as an editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before +//! search started. use std::ops::Range; @@ -150,7 +150,7 @@ impl ChatComposer { /// Handles every key while the footer is acting as the history search input. /// /// The method consumes search-mode keys before normal composer editing sees them. It guarantees - /// that `Esc` restores the original draft, `Enter` only accepts an actual match, plain + /// that `Esc` and Ctrl+C restore the original draft, `Enter` only accepts an actual match, plain /// characters edit the footer query, and navigation keys delegate traversal to /// `ChatComposerHistory`. Calling this when no search session exists is harmless for ignored /// keys but would make query-edit branches no-op, so route here only after @@ -179,6 +179,22 @@ impl ChatComposer { self.cancel_history_search(); (InputResult::None, true) } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c') => { + self.cancel_history_search(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Char('\u{0003}'), + modifiers: KeyModifiers::NONE, + .. + } => { + self.cancel_history_search(); + (InputResult::None, true) + } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -291,12 +307,20 @@ impl ChatComposer { self.apply_history_search_result(result); } - fn cancel_history_search(&mut self) { - if let Some(search) = self.history_search.take() { - self.history.reset_navigation(); - self.footer_mode = reset_mode_after_activity(self.footer_mode); - self.restore_draft(search.original_draft); - } + /// Cancels active history search and restores the draft from before search mode opened. + /// + /// This clears normal history navigation as well as search traversal because previewing a match + /// temporarily updates the shared history cursor. Callers that handle global cancellation, such + /// as Ctrl+C, should use the boolean result to consume the key without also clearing the + /// restored draft or triggering quit/interrupt behavior. + pub(crate) fn cancel_history_search(&mut self) -> bool { + let Some(search) = self.history_search.take() else { + return false; + }; + self.history.reset_navigation(); + self.footer_mode = reset_mode_after_activity(self.footer_mode); + self.restore_draft(search.original_draft); + true } /// Applies a traversal result to the composer preview and search status. @@ -761,6 +785,46 @@ mod tests { assert_eq!(composer.textarea.cursor(), 2); } + #[test] + fn history_search_ctrl_c_restores_original_draft() { + fn composer_with_search_preview() -> ChatComposer { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer + .history + .record_local_submission(HistoryEntry::new("remembered command".to_string())); + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer.textarea.set_cursor(/*pos*/ 2); + + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + assert_eq!(composer.textarea.text(), "remembered command"); + composer + } + + for cancel_key in [ + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('\u{0003}'), KeyModifiers::NONE), + ] { + let mut composer = composer_with_search_preview(); + + let _ = composer.handle_key_event(cancel_key); + + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "draft"); + assert_eq!(composer.textarea.cursor(), 2); + } + } + #[test] fn history_search_esc_resets_normal_history_navigation() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index bd3bd946af8..0273b8e1af9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -7,9 +7,9 @@ //! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs //! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent //! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active -//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may -//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit -//! shortcut. +//! view the first chance to consume Ctrl+C (typically to dismiss itself), then lets an active +//! composer history search consume Ctrl+C as cancellation, and `ChatWidget` may treat an unhandled +//! Ctrl+C as an interrupt or as the first press of a double-press quit shortcut. //! //! Some UI is time-based rather than input-based, such as the transient "press again to quit" //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. @@ -458,7 +458,8 @@ impl BottomPane { /// Handles a Ctrl+C press within the bottom pane. /// /// An active modal view is given the first chance to consume the key (typically to dismiss - /// itself). If no view is active, Ctrl+C clears draft composer input. + /// itself). If no view is active, Ctrl+C cancels active history search before falling back to + /// clearing draft composer input. /// /// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C /// was received, but it does not decide whether the process should exit; `ChatWidget` owns the @@ -475,6 +476,9 @@ impl BottomPane { self.request_redraw(); } event + } else if self.composer.cancel_history_search() { + self.request_redraw(); + CancellationEvent::Handled } else if self.composer_is_empty() { CancellationEvent::NotHandled } else { @@ -1305,6 +1309,31 @@ mod tests { assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); } + #[test] + fn ctrl_c_cancels_history_search_without_clearing_draft_or_showing_quit_hint() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: true, + animations_enabled: true, + skills: Some(Vec::new()), + }); + pane.insert_str("draft"); + + pane.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(pane.composer.popup_active()); + + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert_eq!(pane.composer_text(), "draft"); + assert!(!pane.composer.popup_active()); + assert!(!pane.quit_shortcut_hint_visible()); + } + // live ring removed; related tests deleted. #[test] diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 3a5d2fa0b19..0ad5c693b3c 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -67,7 +67,7 @@ while still providing a richer recall experience for in-session edits. ### Reverse history search (Ctrl+R) -Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. `Enter` accepts the preview as a normal editable draft, and `Esc` restores the exact draft that existed before search started. +Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. `Enter` accepts the preview as a normal editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before search started. The composer owns the search session because it controls draft snapshots, footer rendering, cursor placement, and preview highlighting. `ChatComposerHistory` owns traversal: it scans persistent and local entries in one offset space, skips duplicate prompt text within a search session, keeps boundary hits on the current match, and resumes scans after asynchronous persistent history responses. From 414a917abc3e3b915c4352b0e1ab829116087ceb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 17:27:15 -0300 Subject: [PATCH 11/11] fix(tui): flush paste bursts before history search Flush pending paste-burst input before Ctrl+R snapshots the composer draft for reverse history search. This keeps text typed or pasted just before search from being lost when search is canceled or accepted. Add regression coverage for both a held first character and an active buffered paste burst entering history search. --- .../chat_composer/history_search.rs | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 625bcb37356..b4f6121563e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -126,11 +126,17 @@ impl ChatComposer { /// Opens footer-owned reverse history search without previewing history yet. /// - /// Entering search mode snapshots the full composer draft, clears any file/search popup state, - /// and resets history traversal. The first visible match is produced only after the footer - /// query becomes non-empty, which keeps Ctrl+R from replacing an empty composer with the latest - /// prompt before the user has searched for anything. + /// Entering search mode first flushes pending paste-burst text, then snapshots the full + /// composer draft, clears any file/search popup state, and resets history traversal. The first + /// visible match is produced only after the footer query becomes non-empty, which keeps Ctrl+R + /// from replacing an empty composer with the latest prompt before the user has searched for + /// anything. pub(super) fn begin_history_search(&mut self) -> (InputResult, bool) { + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } + self.paste_burst.clear_window_after_non_char(); + if self.current_file_query.is_some() { self.app_event_tx .send(AppEvent::StartFileSearch(String::new())); @@ -825,6 +831,72 @@ mod tests { } } + #[test] + fn history_search_flushes_pending_first_char_before_snapshot() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.textarea.text(), ""); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(!composer.is_in_paste_burst()); + assert_eq!(composer.textarea.text(), "h"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "h"); + } + + #[test] + fn history_search_flushes_buffered_paste_before_snapshot() { + use std::time::Duration; + use std::time::Instant; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + let mut now = Instant::now(); + for ch in ['p', 'a', 's', 't', 'e'] { + let _ = composer.handle_input_basic_with_time( + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), + now, + ); + now += Duration::from_millis(1); + } + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.textarea.text(), ""); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + + assert!(composer.history_search_active()); + assert!(!composer.is_in_paste_burst()); + assert_eq!(composer.textarea.text(), "paste"); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!composer.history_search_active()); + assert_eq!(composer.textarea.text(), "paste"); + } + #[test] fn history_search_esc_resets_normal_history_navigation() { let (tx, _rx) = unbounded_channel::();