From a46b3bacb084657d894345b8d655616a0880b217 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 26 Jun 2025 22:35:08 -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 | 159 +++++++++++++++++- .../tui/src/bottom_pane/file_search_popup.rs | 158 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 1 + 5 files changed, 316 insertions(+), 4 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..c1b77c6f6c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -58,6 +58,7 @@ tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" uuid = "1" +codex-file-search = { path = "../file-search" } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4ec8299081..14351eb9e7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -16,6 +16,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; @@ -36,8 +37,10 @@ pub enum InputResult { pub(crate) struct ChatComposer<'a> { textarea: TextArea<'a>, command_popup: Option, + file_search_popup: Option, app_event_tx: AppEventSender, history: ChatComposerHistory, + dismissed_file_popup_token: Option, } impl ChatComposer<'_> { @@ -49,8 +52,10 @@ impl ChatComposer<'_> { let mut this = Self { textarea, command_popup: None, + file_search_popup: None, app_event_tx, history: ChatComposerHistory::new(), + dismissed_file_popup_token: None, }; this.update_border(has_input_focus); this @@ -116,19 +121,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); @@ -189,6 +198,87 @@ 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 current @token from textarea last line (without leading '@'). + fn current_at_token(textarea: &tui_textarea::TextArea) -> Option { + let current_line = textarea + .lines() + .last() + .map(|s| s.as_str())?; + let token = current_line.split_whitespace().last()?; + 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(); @@ -286,10 +376,52 @@ impl ChatComposer<'_> { } } + /// Synchronize `self.file_search_popup` with the current text in the textarea. + fn sync_file_search_popup(&mut self) { + // Only consider the last whitespace-separated token on the *current* line. + // We treat the last line as the current line since tui-textarea does not + // expose the cursor position. + let current_line = self + .textarea + .lines() + .last() + .map(|s| s.as_str()) + .unwrap_or(""); + + let last_token = current_line.split_whitespace().last().unwrap_or(""); + + // The token must start with '@' and have at least one character after. + if last_token.starts_with('@') && last_token.len() > 1 { + let query = &last_token[1..]; + + // If user dismissed popup for this exact query, don't reopen until text changes. + if self + .dismissed_file_popup_token + .as_ref() + .map_or(false, |t| t == query) + { + return; + } + let query = &last_token[1..]; + + let popup = self + .file_search_popup + .get_or_insert_with(FileSearchPopup::new); + popup.update_query(query); + self.dismissed_file_popup_token = None; // popup visible, reset + } 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 }; @@ -351,6 +483,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..a1e27c5061 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,158 @@ +use std::num::NonZeroUsize; + +use codex_file_search::{self as file_search, FileSearchResults}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::widgets::{Block, BorderType, Borders, Cell, Row, Table, WidgetRef, Widget}; + +/// 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, + /// Cached search results. + matches: Vec, + selected_idx: Option, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + query: String::new(), + 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.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) -> Vec { + use std::path::PathBuf; + + let limit = NonZeroUsize::new(MAX_RESULTS.max(1)).unwrap(); + let threads = NonZeroUsize::new(4).unwrap(); + + let search_dir: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + // Execute the async search on the current runtime. + use tokio::runtime::{Builder, Handle}; + use tokio::task; + + let fut = file_search::run(prefix, limit, search_dir, Vec::new(), threads); + + let result: anyhow::Result = if let Ok(handle) = Handle::try_current() { + // Already inside a runtime – run the search in a blocking section. + task::block_in_place(|| handle.block_on(fut)) + } else { + // No runtime active; create a lightweight current-thread one. + match Builder::new_current_thread().enable_all().build() { + Ok(rt) => rt.block_on(fut), + Err(e) => { + tracing::error!("failed to build temporary runtime for file search: {e}"); + return Vec::new(); + } + } + }; + + 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 e3234e99a6..3ff806c4d6 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,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;