From f5e171f6891968face0dcf742d791ac91ceec51f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 27 Jun 2025 16:24:47 -0700 Subject: [PATCH] feat: add support for @ to do file search --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 189 +++++++++++++++++- .../tui/src/bottom_pane/file_search_popup.rs | 155 ++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 11 +- codex-rs/tui/src/chatwidget.rs | 1 + 6 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/file_search_popup.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e034a99357..bfc78b65d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -770,6 +770,7 @@ dependencies = [ "codex-ansi-escape", "codex-common", "codex-core", + "codex-file-search", "codex-linux-sandbox", "codex-login", "color-eyre", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 0891517d0e..20b0156186 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -25,6 +25,7 @@ codex-common = { path = "../common", features = [ "elapsed", "sandbox_summary", ] } +codex-file-search = { path = "../file-search" } codex-linux-sandbox = { path = "../linux-sandbox" } codex-login = { path = "../login" } color-eyre = "0.6.3" diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5e5819fa04..08a769e311 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -16,6 +18,7 @@ use tui_textarea::TextArea; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; +use super::file_search_popup::FileSearchPopup; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -39,10 +42,15 @@ pub(crate) struct ChatComposer<'a> { app_event_tx: AppEventSender, history: ChatComposerHistory, ctrl_c_quit_hint: bool, + + /// Current working directory for the conversation. + cwd: PathBuf, + file_search_popup: Option, + dismissed_file_popup_token: Option, } impl ChatComposer<'_> { - pub fn new(has_input_focus: bool, app_event_tx: AppEventSender) -> Self { + pub fn new(has_input_focus: bool, app_event_tx: AppEventSender, cwd: PathBuf) -> Self { let mut textarea = TextArea::default(); textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT); textarea.set_cursor_line_style(ratatui::style::Style::default()); @@ -53,6 +61,9 @@ impl ChatComposer<'_> { app_event_tx, history: ChatComposerHistory::new(), ctrl_c_quit_hint: false, + cwd, + file_search_popup: None, + dismissed_file_popup_token: None, }; this.update_border(has_input_focus); this @@ -123,19 +134,23 @@ impl ChatComposer<'_> { /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - let result = match self.command_popup { - Some(_) => self.handle_key_event_with_popup(key_event), - None => self.handle_key_event_without_popup(key_event), + let result = if self.command_popup.is_some() { + self.handle_key_event_with_slash_popup(key_event) + } else if self.file_search_popup.is_some() { + self.handle_key_event_with_file_popup(key_event) + } else { + self.handle_key_event_without_popup(key_event) }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); + self.sync_file_search_popup(); result } /// Handle key event when the slash-command popup is visible. - fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let Some(popup) = self.command_popup.as_mut() else { tracing::error!("handle_key_event_with_popup called without an active popup"); return (InputResult::None, false); @@ -196,6 +211,126 @@ impl ChatComposer<'_> { } } + /// Handle key events when file search popup is visible. + fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let Some(popup) = self.file_search_popup.as_mut() else { + return (InputResult::None, false); + }; + + match key_event.into() { + Input { key: Key::Up, .. } => { + popup.move_up(); + (InputResult::None, true) + } + Input { key: Key::Down, .. } => { + popup.move_down(); + (InputResult::None, true) + } + Input { key: Key::Esc, .. } => { + // Hide popup without modifying text, remember token to avoid immediate reopen. + if let Some(tok) = Self::current_at_token(&self.textarea) { + self.dismissed_file_popup_token = Some(tok.to_string()); + } + self.file_search_popup = None; + (InputResult::None, true) + } + Input { key: Key::Tab, .. } + | Input { + key: Key::Enter, + ctrl: false, + alt: false, + shift: false, + } => { + if let Some(sel) = popup.selected_match() { + let sel_path = sel.to_string(); + // Drop popup borrow before using self mutably again. + self.insert_selected_path(&sel_path); + self.file_search_popup = None; + return (InputResult::None, true); + } + (InputResult::None, false) + } + input => self.handle_input_basic(input), + } + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + /// + /// Behaviour: + /// • The cursor may be anywhere *inside* the token (including on the + /// leading `@`). It does **not** need to be at the end of the line. + /// • A token is delimited by ASCII whitespace (space, tab, newline). + /// • If the token under the cursor starts with `@` and contains at least + /// one additional character, that token (without `@`) is returned. + fn current_at_token(textarea: &tui_textarea::TextArea) -> Option { + let (row, col) = textarea.cursor(); + + // Guard against out-of-bounds rows. + let line = textarea.lines().get(row)?.as_str(); + + // Clamp the cursor column to the line length to avoid slicing panics + // when the cursor is at the end of the line. + let col = col.min(line.len()); + + // Split the line at the cursor position so we can search for word + // boundaries on both sides. + let before_cursor = &line[..col]; + let after_cursor = &line[col..]; + + // Find start index (first character **after** the previous whitespace). + let start_idx = before_cursor + .rfind(|c: char| c.is_whitespace()) + .map(|idx| idx + 1) + .unwrap_or(0); + + // Find end index (first whitespace **after** the cursor position). + let end_rel_idx = after_cursor + .find(|c: char| c.is_whitespace()) + .unwrap_or(after_cursor.len()); + let end_idx = col + end_rel_idx; + + if start_idx >= end_idx { + return None; + } + + let token = &line[start_idx..end_idx]; + + if token.starts_with('@') && token.len() > 1 { + Some(token[1..].to_string()) + } else { + None + } + } + + /// Replace the active @token with the provided path. + fn insert_selected_path(&mut self, path: &str) { + // Gather full text. + let mut lines: Vec = self.textarea.lines().to_vec(); + if let Some(last) = lines.last_mut() { + let mut parts = last.rsplitn(2, char::is_whitespace); + let _token = parts.next().unwrap_or(""); + let prefix = parts.next().unwrap_or(""); + + // Build new last line. + let mut new_last = String::new(); + new_last.push_str(prefix); + if !prefix.is_empty() { + new_last.push(' '); + } + new_last.push_str(path); + new_last.push(' '); // trailing space after completion + + *last = new_last; + + let new_text = lines.join("\n"); + self.textarea.select_all(); + self.textarea.cut(); + let _ = self.textarea.insert_str(new_text); + } + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let input: Input = key_event.into(); @@ -293,10 +428,35 @@ impl ChatComposer<'_> { } } + /// Synchronize `self.file_search_popup` with the current text in the textarea. + fn sync_file_search_popup(&mut self) { + // Determine if there is an @token underneath the cursor. + if let Some(token) = Self::current_at_token(&self.textarea) { + let query = token; + + // If user dismissed popup for this exact query, don't reopen until text changes. + if self.dismissed_file_popup_token.as_ref() == Some(&query) { + return; + } + + let popup = self + .file_search_popup + .get_or_insert_with(|| FileSearchPopup::new(self.cwd.clone())); + popup.update_query(&query); + self.dismissed_file_popup_token = None; // popup visible again, reset dismissal record + } else { + // Hide the popup when no valid @token is active. + self.file_search_popup = None; + self.dismissed_file_popup_token = None; + } + } + pub fn calculate_required_height(&self, area: &Rect) -> u16 { let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); let num_popup_rows = if let Some(popup) = &self.command_popup { popup.calculate_required_height(area) + } else if let Some(popup) = &self.file_search_popup { + popup.calculate_required_height(area) } else { 0 }; @@ -365,6 +525,25 @@ impl WidgetRef for &ChatComposer<'_> { height: area.height.saturating_sub(popup_rect.height), }; + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } else if let Some(popup) = &self.file_search_popup { + let popup_height = popup.calculate_required_height(&area); + + let popup_rect = Rect { + x: area.x, + y: area.y, + width: area.width, + height: popup_height.min(area.height), + }; + + let textarea_rect = Rect { + x: area.x, + y: area.y + popup_rect.height, + width: area.width, + height: area.height.saturating_sub(popup_rect.height), + }; + popup.render(popup_rect, buf); self.textarea.render(textarea_rect, buf); } 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 new file mode 100644 index 0000000000..3b230373e0 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,155 @@ +use std::num::NonZeroUsize; + +use codex_file_search::FileSearchResults; +use codex_file_search::{self as file_search}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; +use ratatui::widgets::Cell; +use ratatui::widgets::Row; +use ratatui::widgets::Table; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use std::path::Path; +use std::path::PathBuf; +use tokio::runtime::Handle; +use tokio::task; + +/// Maximum number of suggestions shown in the popup. +const MAX_RESULTS: usize = 8; + +pub(crate) struct FileSearchPopup { + /// The query string (text after the `@`). + query: String, + search_dir: PathBuf, + /// Cached search results. + matches: Vec, + selected_idx: Option, +} + +impl FileSearchPopup { + pub(crate) fn new(search_dir: PathBuf) -> Self { + Self { + query: String::new(), + search_dir, + matches: Vec::new(), + selected_idx: None, + } + } + + /// Update the popup based on the `query` prefix. If the query changed a new + /// search is executed (blocking) and the result list refreshed. + pub(crate) fn update_query(&mut self, query: &str) { + if query == self.query { + // No change – nothing to do. + return; + } + + self.query.clear(); + self.query.push_str(query); + + // Perform search synchronously – the underlying implementation is + // reasonably fast for short prefixes and the result count is small + // (MAX_RESULTS). + let matches = Self::search_files(query, &self.search_dir); + self.matches = matches; + + // Reset selection idx. + self.selected_idx = if self.matches.is_empty() { + None + } else { + Some(0) + }; + } + + /// Preferred height (rows) for the popup including borders. + pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + // For the empty-state we still reserve one row so that the border is + // rendered with a minimal height (top + bottom lines). + let rows = self.matches.len().clamp(1, MAX_RESULTS) as u16; + rows + 2 /* border */ + } + + fn search_files(prefix: &str, search_dir: &Path) -> Vec { + #[allow(clippy::unwrap_used)] + let limit = NonZeroUsize::new(MAX_RESULTS.max(1)).unwrap(); + #[allow(clippy::unwrap_used)] + let threads = NonZeroUsize::new(4).unwrap(); + + // Execute the async search on the current runtime. + let future = file_search::run(prefix, limit, search_dir, Vec::new(), threads); + let handle = Handle::current(); + let result: anyhow::Result = + task::block_in_place(|| handle.block_on(future)); + + match result { + Ok(res) => res.matches.into_iter().map(|(_score, path)| path).collect(), + Err(err) => { + tracing::error!("file search failed: {err}"); + Vec::new() + } + } + } + + /// Move selection cursor up. + pub(crate) fn move_up(&mut self) { + if let Some(idx) = self.selected_idx { + if idx > 0 { + self.selected_idx = Some(idx - 1); + } + } + } + + /// Move selection cursor down. + pub(crate) fn move_down(&mut self) { + if let Some(idx) = self.selected_idx { + if idx + 1 < self.matches.len() { + self.selected_idx = Some(idx + 1); + } + } else if !self.matches.is_empty() { + self.selected_idx = Some(0); + } + } + + pub(crate) fn selected_match(&self) -> Option<&str> { + self.selected_idx + .and_then(|i| self.matches.get(i).map(|s| s.as_str())) + } +} + +impl WidgetRef for FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Build table rows – path only. + let mut rows: Vec = Vec::new(); + + if self.matches.is_empty() { + rows.push(Row::new(vec![Cell::from("No matches").italic()])); + } else { + for (idx, path) in self.matches.iter().take(MAX_RESULTS).enumerate() { + let mut cell = Cell::from(path.clone()); + if Some(idx) == self.selected_idx { + cell = cell.style(Style::default().fg(Color::Black).bg(Color::White)); + } + rows.push(Row::new(vec![cell])); + } + } + + let table = Table::new(rows, &[ratatui::layout::Constraint::Percentage(100)]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(format!("@{query}", query = self.query)) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .column_spacing(1); + + // Consume the table and render it. + table.render(area, buf); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d9b1fcc96c..00c72dd779 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,5 +1,7 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. +use std::path::PathBuf; + use bottom_pane_view::BottomPaneView; use bottom_pane_view::ConditionalUpdate; use codex_core::protocol::TokenUsage; @@ -17,6 +19,7 @@ mod bottom_pane_view; mod chat_composer; mod chat_composer_history; mod command_popup; +mod file_search_popup; mod status_indicator_view; pub(crate) use chat_composer::ChatComposer; @@ -43,12 +46,18 @@ pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, pub(crate) has_input_focus: bool, + /// Current working directory for the conversation. + pub(crate) cwd: PathBuf, } impl BottomPane<'_> { pub fn new(params: BottomPaneParams) -> Self { Self { - composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()), + composer: ChatComposer::new( + params.has_input_focus, + params.app_event_tx.clone(), + params.cwd, + ), active_view: None, app_event_tx: params.app_event_tx, has_input_focus: params.has_input_focus, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 78e828f02b..93a150b388 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -126,6 +126,7 @@ impl ChatWidget<'_> { bottom_pane: BottomPane::new(BottomPaneParams { app_event_tx, has_input_focus: true, + cwd: config.cwd.clone(), }), input_focus: InputFocus::BottomPane, config,