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_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<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_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<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`.
let current_opt = Self::current_at_token(&self.textarea);
let Some(current_token) = current_opt else {

View File

@@ -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<String>,
matches: Vec<FileMatch>,
/// Currently selected index inside `matches` (if any).
selected_idx: Option<usize>,
}
@@ -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<String>) {
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
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<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 {
cell = cell.style(Style::default().fg(Color::Yellow));
}
Row::new(vec![cell])
})
.collect()

View File

@@ -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<String>) {
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.composer.on_file_search_result(query, matches);
self.request_redraw();
}

View File

@@ -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<String>) {
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
self.bottom_pane.on_file_search_result(query, matches);
}

View File

@@ -165,7 +165,7 @@ impl FileSearchManager {
cancellation_token: Arc<AtomicBool>,
search_state: Arc<Mutex<SearchState>>,
) {
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::<Vec<String>>()
})
.map(|res| res.matches)
.unwrap_or_default();
let is_cancelled = cancellation_token.load(Ordering::Relaxed);