From bd92cc30f1e916fd3d32c42fd5eae12889ae3abb 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 | 320 +++++++++++++++--- .../tui/src/bottom_pane/file_search_popup.rs | 155 +++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 17 +- codex-rs/tui/src/chatwidget.rs | 3 +- 6 files changed, 445 insertions(+), 52 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..02778c1fe1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -14,8 +14,11 @@ use tui_textarea::Input; use tui_textarea::Key; use tui_textarea::TextArea; +use std::path::PathBuf; + 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; @@ -35,24 +38,35 @@ pub enum InputResult { pub(crate) struct ChatComposer<'a> { textarea: TextArea<'a>, - command_popup: Option, + active_popup: ActivePopup, app_event_tx: AppEventSender, history: ChatComposerHistory, ctrl_c_quit_hint: bool, + cwd: std::path::PathBuf, + dismissed_file_popup_token: Option, +} + +/// Popup state – at most one can be visible at any time. +enum ActivePopup { + None, + Command(CommandPopup), + File(FileSearchPopup), } 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()); let mut this = Self { textarea, - command_popup: None, + active_popup: ActivePopup::None, app_event_tx, history: ChatComposerHistory::new(), ctrl_c_quit_hint: false, + cwd, + dismissed_file_popup_token: None, }; this.update_border(has_input_focus); this @@ -123,22 +137,27 @@ 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 = match &mut self.active_popup { + ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), + ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. self.sync_command_popup(); + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + } else { + 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) { - 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); + fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + let ActivePopup::Command(popup) = &mut self.active_popup else { + unreachable!(); }; match key_event.into() { @@ -186,7 +205,7 @@ impl ChatComposer<'_> { self.textarea.cut(); // Hide popup since the command has been dispatched. - self.command_popup = None; + self.active_popup = ActivePopup::None; return (InputResult::None, true); } // Fallback to default newline handling if no command selected. @@ -196,6 +215,149 @@ 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 ActivePopup::File(popup) = &mut self.active_popup else { + unreachable!(); + }; + + 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.active_popup = ActivePopup::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.active_popup = ActivePopup::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` (the one under the cursor) with `path`. + /// + /// The algorithm mirrors `current_at_token` so replacement works no matter + /// where the cursor is within the token and regardless of how many + /// `@tokens` exist in the line. + fn insert_selected_path(&mut self, path: &str) { + let (row, col) = self.textarea.cursor(); + + // Materialize the textarea lines so we can mutate them easily. + let mut lines: Vec = self.textarea.lines().to_vec(); + + if let Some(line) = lines.get_mut(row) { + let col = col.min(line.len()); + + let before_cursor = &line[..col]; + let after_cursor = &line[col..]; + + // Determine token boundaries. + let start_idx = before_cursor + .rfind(|c: char| c.is_whitespace()) + .map(|idx| idx + 1) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .find(|c: char| c.is_whitespace()) + .unwrap_or(after_cursor.len()); + let end_idx = col + end_rel_idx; + + // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. + let mut new_line = + String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1); + new_line.push_str(&line[..start_idx]); + new_line.push_str(path); + new_line.push(' '); + new_line.push_str(&line[end_idx..]); + + *line = new_line; + + // Re-populate the textarea. + let new_text = lines.join("\n"); + self.textarea.select_all(); + self.textarea.cut(); + let _ = self.textarea.insert_str(new_text); + + // Note: tui-textarea currently exposes only relative cursor + // movements. Leaving the cursor position unchanged is acceptable + // as subsequent typing will move the cursor naturally. + } + } + /// 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(); @@ -280,25 +442,63 @@ impl ChatComposer<'_> { .map(|s| s.as_str()) .unwrap_or(""); - if first_line.starts_with('/') { - // Create popup lazily when the user starts a slash command. - let popup = self.command_popup.get_or_insert_with(CommandPopup::new); - - // Forward *only* the first line since `CommandPopup` only needs - // the command token. - popup.on_composer_text_change(first_line.to_string()); - } else if self.command_popup.is_some() { - // Remove popup when '/' is no longer the first character. - self.command_popup = None; + let input_starts_with_slash = first_line.starts_with('/'); + match &mut self.active_popup { + ActivePopup::Command(popup) => { + if input_starts_with_slash { + popup.on_composer_text_change(first_line.to_string()); + } else { + self.active_popup = ActivePopup::None; + } + } + _ => { + if input_starts_with_slash { + let mut command_popup = CommandPopup::new(); + command_popup.on_composer_text_change(first_line.to_string()); + self.active_popup = ActivePopup::Command(command_popup); + } + } } } + /// Synchronize `self.file_search_popup` with the current text in the textarea. + /// Note this is only called when self.active_popup is NOT Command. + fn sync_file_search_popup(&mut self) { + // Determine if there is an @token underneath the cursor. + let query = match Self::current_at_token(&self.textarea) { + Some(token) => token, + None => { + self.active_popup = ActivePopup::None; + self.dismissed_file_popup_token = None; + return; + } + }; + + // 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; + } + + match &mut self.active_popup { + ActivePopup::File(popup) => { + popup.update_query(&query); + } + _ => { + // Create a new file search popup with the current query. + let mut popup = FileSearchPopup::new(self.cwd.clone()); + popup.update_query(&query); + self.active_popup = ActivePopup::File(popup); + } + } + 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 { - 0 + let num_popup_rows = match &self.active_popup { + ActivePopup::Command(popup) => popup.calculate_required_height(area), + ActivePopup::File(popup) => popup.calculate_required_height(area), + ActivePopup::None => 0, }; rows as u16 + BORDER_LINES + num_popup_rows @@ -339,36 +539,62 @@ impl ChatComposer<'_> { ); } - pub(crate) fn is_command_popup_visible(&self) -> bool { - self.command_popup.is_some() + pub(crate) fn is_popup_visible(&self) -> bool { + match self.active_popup { + ActivePopup::Command(_) | ActivePopup::File(_) => true, + ActivePopup::None => false, + } } } impl WidgetRef for &ChatComposer<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if let Some(popup) = &self.command_popup { - let popup_height = popup.calculate_required_height(&area); + match &self.active_popup { + ActivePopup::Command(popup) => { + let popup_height = popup.calculate_required_height(&area); - // Split the provided rect so that the popup is rendered at the - // *top* and the textarea occupies the remaining space below. - let popup_rect = Rect { - x: area.x, - y: area.y, - width: area.width, - height: popup_height.min(area.height), - }; + // Split the provided rect so that the popup is rendered at the + // *top* and the textarea occupies the remaining space below. + 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), - }; + 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 { - self.textarea.render(area, buf); + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } + ActivePopup::File(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_height), + }; + + popup.render(popup_rect, buf); + self.textarea.render(textarea_rect, buf); + } + ActivePopup::None => { + self.textarea.render(area, buf); + } } } } 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..aeaf152eab 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, @@ -201,9 +210,9 @@ impl BottomPane<'_> { self.app_event_tx.send(AppEvent::Redraw) } - /// Returns true when the slash-command popup inside the composer is visible. - pub(crate) fn is_command_popup_visible(&self) -> bool { - self.active_view.is_none() && self.composer.is_command_popup_visible() + /// Returns true when a popup inside the composer is visible. + pub(crate) fn is_popup_visible(&self) -> bool { + self.active_view.is_none() && self.composer.is_popup_visible() } // --- History helpers --- diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 78e828f02b..d6f3c10a30 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, @@ -143,7 +144,7 @@ impl ChatWidget<'_> { // However, when the slash-command popup is visible we forward the key // to the bottom pane so it can handle auto-completion. if matches!(key_event.code, crossterm::event::KeyCode::Tab) - && !self.bottom_pane.is_command_popup_visible() + && !self.bottom_pane.is_popup_visible() { self.input_focus = match self.input_focus { InputFocus::HistoryPane => InputFocus::BottomPane,