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
This commit is contained in:
Michael Bolin
2025-06-28 15:04:23 -07:00
committed by GitHub
parent e2efe8da9c
commit 4a341efe92
6 changed files with 51 additions and 24 deletions

View File

@@ -1,4 +1,5 @@
use codex_core::protocol::Event; use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
@@ -39,6 +40,6 @@ pub(crate) enum AppEvent {
/// still relevant. /// still relevant.
FileSearchResult { FileSearchResult {
query: String, query: String,
matches: Vec<String>, matches: Vec<FileMatch>,
}, },
} }

View File

@@ -20,6 +20,7 @@ use super::file_search_popup::FileSearchPopup;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use codex_file_search::FileMatch;
/// Minimum number of visible text rows inside the textarea. /// Minimum number of visible text rows inside the textarea.
const MIN_TEXTAREA_ROWS: usize = 1; const MIN_TEXTAREA_ROWS: usize = 1;
@@ -129,7 +130,7 @@ impl ChatComposer<'_> {
} }
/// Integrate results from an asynchronous file search. /// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) { pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Only apply if user is still editing a token starting with `query`. // Only apply if user is still editing a token starting with `query`.
let current_opt = Self::current_at_token(&self.textarea); let current_opt = Self::current_at_token(&self.textarea);
let Some(current_token) = current_opt else { let Some(current_token) = current_opt else {

View File

@@ -1,8 +1,12 @@
use codex_file_search::FileMatch;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Constraint; use ratatui::prelude::Constraint;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block; use ratatui::widgets::Block;
use ratatui::widgets::BorderType; use ratatui::widgets::BorderType;
use ratatui::widgets::Borders; use ratatui::widgets::Borders;
@@ -25,7 +29,7 @@ pub(crate) struct FileSearchPopup {
/// When `true` we are still waiting for results for `pending_query`. /// When `true` we are still waiting for results for `pending_query`.
waiting: bool, waiting: bool,
/// Cached matches; paths relative to the search dir. /// Cached matches; paths relative to the search dir.
matches: Vec<String>, matches: Vec<FileMatch>,
/// Currently selected index inside `matches` (if any). /// Currently selected index inside `matches` (if any).
selected_idx: Option<usize>, selected_idx: Option<usize>,
} }
@@ -63,7 +67,7 @@ impl FileSearchPopup {
/// Replace matches when a `FileSearchResult` arrives. /// Replace matches when a `FileSearchResult` arrives.
/// Replace matches. Only applied when `query` matches `pending_query`. /// Replace matches. Only applied when `query` matches `pending_query`.
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<String>) { pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
if query != self.pending_query { if query != self.pending_query {
return; // stale return; // stale
} }
@@ -101,7 +105,7 @@ impl FileSearchPopup {
pub(crate) fn selected_match(&self) -> Option<&str> { pub(crate) fn selected_match(&self) -> Option<&str> {
self.selected_idx self.selected_idx
.and_then(|idx| self.matches.get(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. /// Preferred height (rows) including border.
@@ -130,11 +134,36 @@ impl WidgetRef for &FileSearchPopup {
.iter() .iter()
.take(MAX_RESULTS) .take(MAX_RESULTS)
.enumerate() .enumerate()
.map(|(i, p)| { .map(|(i, file_match)| {
let mut cell = Cell::from(p.as_str()); 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<Span> = 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 { if Some(i) == self.selected_idx {
cell = cell.style(Style::default().fg(Color::Yellow)); cell = cell.style(Style::default().fg(Color::Yellow));
} }
Row::new(vec![cell]) Row::new(vec![cell])
}) })
.collect() .collect()

View File

@@ -1,16 +1,16 @@
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. //! 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::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest; 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 approval_modal_view;
mod bottom_pane_view; mod bottom_pane_view;
@@ -228,7 +228,7 @@ impl BottomPane<'_> {
} }
} }
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<String>) { pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.composer.on_file_search_result(query, matches); self.composer.on_file_search_result(query, matches);
self.request_redraw(); self.request_redraw();
} }

View File

@@ -38,6 +38,7 @@ use crate::bottom_pane::InputResult;
use crate::conversation_history_widget::ConversationHistoryWidget; use crate::conversation_history_widget::ConversationHistoryWidget;
use crate::history_cell::PatchEventType; use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest; use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
pub(crate) struct ChatWidget<'a> { pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
@@ -405,7 +406,7 @@ impl ChatWidget<'_> {
} }
/// Forward file-search results to the bottom pane. /// Forward file-search results to the bottom pane.
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<String>) { pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches); self.bottom_pane.on_file_search_result(query, matches);
} }

View File

@@ -165,7 +165,7 @@ impl FileSearchManager {
cancellation_token: Arc<AtomicBool>, cancellation_token: Arc<AtomicBool>,
search_state: Arc<Mutex<SearchState>>, search_state: Arc<Mutex<SearchState>>,
) { ) {
let compute_indices = false; let compute_indices = true;
std::thread::spawn(move || { std::thread::spawn(move || {
let matches = file_search::run( let matches = file_search::run(
&query, &query,
@@ -176,12 +176,7 @@ impl FileSearchManager {
cancellation_token.clone(), cancellation_token.clone(),
compute_indices, compute_indices,
) )
.map(|res| { .map(|res| res.matches)
res.matches
.into_iter()
.map(|m| m.path)
.collect::<Vec<String>>()
})
.unwrap_or_default(); .unwrap_or_default();
let is_cancelled = cancellation_token.load(Ordering::Relaxed); let is_cancelled = cancellation_token.load(Ordering::Relaxed);