diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e034a993..bfc78b65 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 0891517d..20b01561 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/app.rs b/codex-rs/tui/src/app.rs index 4c8f004a..4b8b9b78 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,6 +1,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; +use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; @@ -43,6 +44,8 @@ pub(crate) struct App<'a> { /// Config is stored here so we can recreate ChatWidgets as needed. config: Config, + file_search: FileSearchManager, + /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, @@ -156,11 +159,13 @@ impl<'a> App<'a> { ) }; + let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); Self { app_event_tx, app_event_rx, app_state, config, + file_search, chat_args, } } @@ -273,6 +278,14 @@ impl<'a> App<'a> { } } }, + AppEvent::StartFileSearch(query) => { + self.file_search.on_user_query(query); + } + AppEvent::FileSearchResult { query, matches } => { + if let AppState::Chat { widget } = &mut self.app_state { + widget.apply_file_search_result(query, matches); + } + } } } terminal.clear()?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 8fc55752..e8a7e65c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -28,4 +28,17 @@ pub(crate) enum AppEvent { /// Dispatch a recognized slash command from the UI (composer) to the app /// layer so it can be handled centrally. DispatchCommand(SlashCommand), + + /// Kick off an asynchronous file search for the given query (text after + /// the `@`). Previous searches may be cancelled by the app layer so there + /// is at most one in-flight search. + StartFileSearch(String), + + /// Result of a completed asynchronous file search. The `query` echoes the + /// original search term so the UI can decide whether the results are + /// still relevant. + FileSearchResult { + query: String, + matches: Vec, + }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5e5819fa..a3665a70 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; @@ -35,10 +36,19 @@ 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, + dismissed_file_popup_token: Option, + current_file_query: Option, +} + +/// Popup state – at most one can be visible at any time. +enum ActivePopup { + None, + Command(CommandPopup), + File(FileSearchPopup), } impl ChatComposer<'_> { @@ -49,10 +59,12 @@ impl ChatComposer<'_> { let mut this = Self { textarea, - command_popup: None, + active_popup: ActivePopup::None, app_event_tx, history: ChatComposerHistory::new(), ctrl_c_quit_hint: false, + dismissed_file_popup_token: None, + current_file_query: None, }; this.update_border(has_input_focus); this @@ -116,6 +128,23 @@ impl ChatComposer<'_> { self.update_border(has_focus); } + /// Integrate results from an asynchronous file search. + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + // 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 { + return; + }; + + if !current_token.starts_with(&query) { + return; + } + + if let ActivePopup::File(popup) = &mut self.active_popup { + popup.set_matches(&query, matches); + } + } + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { self.ctrl_c_quit_hint = show; self.update_border(has_focus); @@ -123,22 +152,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 +220,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 +230,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 `@`. + /// + /// Behavior: + /// - 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 +457,67 @@ 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; + } + + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + + match &mut self.active_popup { + ActivePopup::File(popup) => { + popup.set_query(&query); + } + _ => { + let mut popup = FileSearchPopup::new(); + popup.set_query(&query); + self.active_popup = ActivePopup::File(popup); + } + } + + self.current_file_query = Some(query); + 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 +558,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 00000000..02b511be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -0,0 +1,159 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Constraint; +use ratatui::style::Color; +use ratatui::style::Style; +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; + +/// Maximum number of suggestions shown in the popup. +const MAX_RESULTS: usize = 8; + +/// Visual state for the file-search popup. +pub(crate) struct FileSearchPopup { + /// Query corresponding to the `matches` currently shown. + display_query: String, + /// Latest query typed by the user. May differ from `display_query` when + /// a search is still in-flight. + pending_query: String, + /// When `true` we are still waiting for results for `pending_query`. + waiting: bool, + /// Cached matches; paths relative to the search dir. + matches: Vec, + /// Currently selected index inside `matches` (if any). + selected_idx: Option, +} + +impl FileSearchPopup { + pub(crate) fn new() -> Self { + Self { + display_query: String::new(), + pending_query: String::new(), + waiting: true, + matches: Vec::new(), + selected_idx: None, + } + } + + /// Update the query and reset state to *waiting*. + pub(crate) fn set_query(&mut self, query: &str) { + if query == self.pending_query { + return; + } + + // Determine if current matches are still relevant. + let keep_existing = query.starts_with(&self.display_query); + + self.pending_query.clear(); + self.pending_query.push_str(query); + + self.waiting = true; // waiting for new results + + if !keep_existing { + self.matches.clear(); + self.selected_idx = None; + } + } + + /// 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) { + if query != self.pending_query { + return; // stale + } + + self.display_query = query.to_string(); + self.matches = matches; + self.waiting = false; + self.selected_idx = if self.matches.is_empty() { + None + } else { + Some(0) + }; + } + + /// 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(|idx| self.matches.get(idx)) + .map(String::as_str) + } + + /// Preferred height (rows) including border. + pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 { + // Row count depends on whether we already have matches. If no matches + // yet (e.g. initial search or query with no results) reserve a single + // row so the popup is still visible. When matches are present we show + // up to MAX_RESULTS regardless of the waiting flag so the list + // remains stable while a newer search is in-flight. + let rows = if self.matches.is_empty() { + 1 + } else { + self.matches.len().clamp(1, MAX_RESULTS) + } as u16; + rows + 2 // border + } +} + +impl WidgetRef for &FileSearchPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Prepare rows. + let rows: Vec = if self.matches.is_empty() { + vec![Row::new(vec![Cell::from(" no matches ")])] + } else { + self.matches + .iter() + .take(MAX_RESULTS) + .enumerate() + .map(|(i, p)| { + let mut cell = Cell::from(p.as_str()); + if Some(i) == self.selected_idx { + cell = cell.style(Style::default().fg(Color::Yellow)); + } + Row::new(vec![cell]) + }) + .collect() + }; + + let mut title = format!(" @{} ", self.pending_query); + if self.waiting { + title.push_str(" (searching …)"); + } + + let table = Table::new(rows, vec![Constraint::Percentage(100)]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .title(title), + ) + .widths([Constraint::Percentage(100)]); + + 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 d9b1fcc9..c7755d32 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; @@ -201,9 +202,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 --- @@ -226,6 +227,11 @@ impl BottomPane<'_> { self.request_redraw(); } } + + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { + self.composer.on_file_search_result(query, matches); + self.request_redraw(); + } } impl WidgetRef for &BottomPane<'_> { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 78e828f0..a5617a79 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -143,7 +143,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, @@ -404,6 +404,11 @@ impl ChatWidget<'_> { self.request_redraw(); } + /// Forward file-search results to the bottom pane. + pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { + self.bottom_pane.on_file_search_result(query, matches); + } + /// Handle Ctrl-C key press. /// Returns true if the key press was handled, false if it was not. /// If the key press was not handled, the caller should handle it (likely by exiting the process). diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs new file mode 100644 index 00000000..7a76a1f0 --- /dev/null +++ b/codex-rs/tui/src/file_search.rs @@ -0,0 +1,204 @@ +//! Helper that owns the debounce/cancellation logic for `@` file searches. +//! +//! `ChatComposer` publishes *every* change of the `@token` as +//! `AppEvent::StartFileSearch(query)`. +//! This struct receives those events and decides when to actually spawn the +//! expensive search (handled in the main `App` thread). It tries to ensure: +//! +//! - Even when the user types long text quickly, they will start seeing results +//! after a short delay using an early version of what they typed. +//! - At most one search is in-flight at any time. +//! +//! It works as follows: +//! +//! 1. First query starts a debounce timer. +//! 2. While the timer is pending, the latest query from the user is stored. +//! 3. When the timer fires, it is cleared, and a search is done for the most +//! recent query. +//! 4. If there is a in-flight search that is not a prefix of the latest thing +//! the user typed, it is cancelled. + +use codex_file_search as file_search; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::thread; +use std::time::Duration; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +#[allow(clippy::unwrap_used)] +const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(8).unwrap(); + +#[allow(clippy::unwrap_used)] +const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap(); + +/// How long to wait after a keystroke before firing the first search when none +/// is currently running. Keeps early queries more meaningful. +const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); + +const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// State machine for file-search orchestration. +pub(crate) struct FileSearchManager { + /// Unified state guarded by one mutex. + state: Arc>, + + search_dir: PathBuf, + app_tx: AppEventSender, +} + +struct SearchState { + /// Latest query typed by user (updated every keystroke). + latest_query: String, + + /// true if a search is currently scheduled. + is_search_scheduled: bool, + + /// If there is an active search, this will be the query being searched. + active_search: Option, +} + +struct ActiveSearch { + query: String, + cancellation_token: Arc, +} + +impl FileSearchManager { + pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { + Self { + state: Arc::new(Mutex::new(SearchState { + latest_query: String::new(), + is_search_scheduled: false, + active_search: None, + })), + search_dir, + app_tx: tx, + } + } + + /// Call whenever the user edits the `@` token. + pub fn on_user_query(&self, query: String) { + { + #[allow(clippy::unwrap_used)] + let mut st = self.state.lock().unwrap(); + if query == st.latest_query { + // No change, nothing to do. + return; + } + + // Update latest query. + st.latest_query.clear(); + st.latest_query.push_str(&query); + + // If there is an in-flight search that is definitely obsolete, + // cancel it now. + if let Some(active_search) = &st.active_search { + if !query.starts_with(&active_search.query) { + active_search + .cancellation_token + .store(true, Ordering::Relaxed); + st.active_search = None; + } + } + + // Schedule a search to run after debounce. + if !st.is_search_scheduled { + st.is_search_scheduled = true; + } else { + return; + } + } + + // If we are here, we set `st.is_search_scheduled = true` before + // dropping the lock. This means we are the only thread that can spawn a + // debounce timer. + let state = self.state.clone(); + let search_dir = self.search_dir.clone(); + let tx_clone = self.app_tx.clone(); + thread::spawn(move || { + // Always do a minimum debounce, but then poll until the + // `active_search` is cleared. + thread::sleep(FILE_SEARCH_DEBOUNCE); + loop { + #[allow(clippy::unwrap_used)] + if state.lock().unwrap().active_search.is_none() { + break; + } + thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL); + } + + // The debounce timer has expired, so start a search using the + // latest query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + let query = { + #[allow(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + let query = st.latest_query.clone(); + st.is_search_scheduled = false; + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + query + }; + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + }); + } + + fn spawn_file_search( + query: String, + search_dir: PathBuf, + tx: AppEventSender, + cancellation_token: Arc, + search_state: Arc>, + ) { + std::thread::spawn(move || { + let matches = file_search::run( + &query, + MAX_FILE_SEARCH_RESULTS, + &search_dir, + Vec::new(), + NUM_FILE_SEARCH_THREADS, + cancellation_token.clone(), + ) + .map(|res| { + res.matches + .into_iter() + .map(|(_, p)| p) + .collect::>() + }) + .unwrap_or_default(); + + let is_cancelled = cancellation_token.load(Ordering::Relaxed); + if !is_cancelled { + tx.send(AppEvent::FileSearchResult { query, matches }); + } + + // Reset the active search state. Do a pointer comparison to verify + // that we are clearing the ActiveSearch that corresponds to the + // cancellation token we were given. + { + #[allow(clippy::unwrap_used)] + let mut st = search_state.lock().unwrap(); + if let Some(active_search) = &st.active_search { + if Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) { + st.active_search = None; + } + } + } + }); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b17bb042..317cd57f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -29,6 +29,7 @@ mod citation_regex; mod cli; mod conversation_history_widget; mod exec_command; +mod file_search; mod get_git_diff; mod git_warning_screen; mod history_cell;