From 4a341efe922522cb0a0ce2e73f88257a4f509207 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sat, 28 Jun 2025 15:04:23 -0700 Subject: [PATCH] feat: highlight matching characters in fuzzy file search (#1420) Using the new file-search API introduced in https://github.com/openai/codex/pull/1419, matching characters are now shown in bold in the TUI: https://github.com/user-attachments/assets/8bbcc6c6-75a3-493f-8ea4-b2a063e09b3a Fixes https://github.com/openai/codex/issues/1261 --- codex-rs/tui/src/app_event.rs | 3 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 3 +- .../tui/src/bottom_pane/file_search_popup.rs | 39 ++++++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 18 ++++----- codex-rs/tui/src/chatwidget.rs | 3 +- codex-rs/tui/src/file_search.rs | 9 +---- 6 files changed, 51 insertions(+), 24 deletions(-) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e8a7e65c..dd89b853 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,4 +1,5 @@ use codex_core::protocol::Event; +use codex_file_search::FileMatch; use crossterm::event::KeyEvent; use crate::slash_command::SlashCommand; @@ -39,6 +40,6 @@ pub(crate) enum AppEvent { /// still relevant. FileSearchResult { query: String, - matches: Vec, + matches: Vec, }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a3665a70..59d6e457 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -20,6 +20,7 @@ use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use codex_file_search::FileMatch; /// Minimum number of visible text rows inside the textarea. const MIN_TEXTAREA_ROWS: usize = 1; @@ -129,7 +130,7 @@ impl ChatComposer<'_> { } /// Integrate results from an asynchronous file search. - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // Only apply if user is still editing a token starting with `query`. let current_opt = Self::current_at_token(&self.textarea); let Some(current_token) = current_opt else { diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 02b511be..34eb59e4 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -1,8 +1,12 @@ +use codex_file_search::FileMatch; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Constraint; use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; @@ -25,7 +29,7 @@ pub(crate) struct FileSearchPopup { /// When `true` we are still waiting for results for `pending_query`. waiting: bool, /// Cached matches; paths relative to the search dir. - matches: Vec, + matches: Vec, /// Currently selected index inside `matches` (if any). selected_idx: Option, } @@ -63,7 +67,7 @@ impl FileSearchPopup { /// Replace matches when a `FileSearchResult` arrives. /// Replace matches. Only applied when `query` matches `pending_query`. - pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { + pub(crate) fn set_matches(&mut self, query: &str, matches: Vec) { if query != self.pending_query { return; // stale } @@ -101,7 +105,7 @@ impl FileSearchPopup { pub(crate) fn selected_match(&self) -> Option<&str> { self.selected_idx .and_then(|idx| self.matches.get(idx)) - .map(String::as_str) + .map(|file_match| file_match.path.as_str()) } /// Preferred height (rows) including border. @@ -130,11 +134,36 @@ impl WidgetRef for &FileSearchPopup { .iter() .take(MAX_RESULTS) .enumerate() - .map(|(i, p)| { - let mut cell = Cell::from(p.as_str()); + .map(|(i, file_match)| { + let FileMatch { path, indices, .. } = file_match; + let path = path.as_str(); + #[allow(clippy::expect_used)] + let indices = indices.as_ref().expect("indices should be present"); + + // Build spans with bold on matching indices. + let mut idx_iter = indices.iter().peekable(); + let mut spans: Vec = Vec::with_capacity(path.len()); + + for (char_idx, ch) in path.chars().enumerate() { + let mut style = Style::default(); + if idx_iter + .peek() + .is_some_and(|next| **next == char_idx as u32) + { + idx_iter.next(); + style = style.add_modifier(Modifier::BOLD); + } + spans.push(Span::styled(ch.to_string(), style)); + } + + // Create cell from the spans. + let mut cell = Cell::from(Line::from(spans)); + + // If selected, also paint yellow. if Some(i) == self.selected_idx { cell = cell.style(Style::default().fg(Color::Yellow)); } + Row::new(vec![cell]) }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c7755d32..96f5c702 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,16 +1,16 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. -use bottom_pane_view::BottomPaneView; -use bottom_pane_view::ConditionalUpdate; -use codex_core::protocol::TokenUsage; -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::user_approval_widget::ApprovalRequest; +use bottom_pane_view::BottomPaneView; +use bottom_pane_view::ConditionalUpdate; +use codex_core::protocol::TokenUsage; +use codex_file_search::FileMatch; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; mod approval_modal_view; mod bottom_pane_view; @@ -228,7 +228,7 @@ impl BottomPane<'_> { } } - pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { self.composer.on_file_search_result(query, matches); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a5617a79..0b623132 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -38,6 +38,7 @@ use crate::bottom_pane::InputResult; use crate::conversation_history_widget::ConversationHistoryWidget; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; +use codex_file_search::FileMatch; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, @@ -405,7 +406,7 @@ impl ChatWidget<'_> { } /// Forward file-search results to the bottom pane. - pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { + pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); } diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index 77eee35b..9fb010a0 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -165,7 +165,7 @@ impl FileSearchManager { cancellation_token: Arc, search_state: Arc>, ) { - let compute_indices = false; + let compute_indices = true; std::thread::spawn(move || { let matches = file_search::run( &query, @@ -176,12 +176,7 @@ impl FileSearchManager { cancellation_token.clone(), compute_indices, ) - .map(|res| { - res.matches - .into_iter() - .map(|m| m.path) - .collect::>() - }) + .map(|res| res.matches) .unwrap_or_default(); let is_cancelled = cancellation_token.load(Ordering::Relaxed);