From bf46b34f30fe774dc8facc0975313a1912d2e69e Mon Sep 17 00:00:00 2001 From: Alexander Embiricos Date: Sat, 9 Aug 2025 21:03:43 -0700 Subject: [PATCH] feat: render @-mention suggestions for empty queries - This is more helpful in helping users understand how the feature works. --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 18 +-- .../tui/src/bottom_pane/file_search_popup.rs | 11 -- codex-rs/tui/src/file_search.rs | 106 ++++++++++++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d12eb76e8d..4d818e981a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -485,9 +485,7 @@ impl App<'_> { } } AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } + self.file_search.on_user_query(query); } AppEvent::FileSearchResult { query, matches } => { if let AppState::Chat { widget } = &mut self.app_state { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 71483f0495..5a438f9272 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -603,26 +603,16 @@ impl ChatComposer { return; } - if !query.is_empty() { - self.app_event_tx - .send(AppEvent::StartFileSearch(query.clone())); - } + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); match &mut self.active_popup { ActivePopup::File(popup) => { - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } + popup.set_query(&query); } _ => { let mut popup = FileSearchPopup::new(); - if query.is_empty() { - popup.set_empty_prompt(); - } else { - popup.set_query(&query); - } + popup.set_query(&query); self.active_popup = ActivePopup::File(popup); } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index a811a22a8c..c30a24f984 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -54,17 +54,6 @@ impl FileSearchPopup { } } - /// Put the popup into an "idle" state used for an empty query (just "@"). - /// Shows a hint instead of matches until the user types more characters. - pub(crate) fn set_empty_prompt(&mut self) { - self.display_query.clear(); - self.pending_query.clear(); - self.waiting = false; - self.matches.clear(); - // Reset selection/scroll state when showing the empty prompt. - self.state.reset(); - } - /// 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) { diff --git a/codex-rs/tui/src/file_search.rs b/codex-rs/tui/src/file_search.rs index 8a05b2c165..9e5274518d 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -83,6 +83,23 @@ impl FileSearchManager { { #[expect(clippy::unwrap_used)] let mut st = self.state.lock().unwrap(); + // If the query is empty, build quick suggestions immediately and return. + // Do this BEFORE the unchanged short-circuit so the initial empty + // query ("@") still yields results even though latest_query starts empty. + if query.is_empty() { + let search_dir = self.search_dir.clone(); + let tx = self.app_tx.clone(); + std::thread::spawn(move || { + let max_total = MAX_FILE_SEARCH_RESULTS.get(); + let matches = collect_top_level_suggestions(&search_dir, max_total); + tx.send(AppEvent::FileSearchResult { + query: String::new(), + matches, + }); + }); + return; + } + if query == st.latest_query { // No change, nothing to do. return; @@ -196,3 +213,92 @@ impl FileSearchManager { }); } } + +/// Build a small, fast set of suggestions for an empty `@` mention. +/// Strategy: list top-level non-hidden files first, then top-level directories +/// (with trailing '/'), capped by `max_total`. +fn collect_top_level_suggestions( + cwd: &std::path::Path, + max_total: usize, +) -> Vec { + use std::collections::HashSet; + use std::fs; + + let mut seen: HashSet = HashSet::new(); + let mut out: Vec = Vec::new(); + let mut total_added: usize = 0; + + // 1) Top-level non-hidden files in cwd (files only). + if let Ok(rd) = fs::read_dir(cwd) { + for entry in rd.flatten() { + if total_added >= max_total { + break; + } + let path = entry.path(); + let file_name = match path.strip_prefix(cwd).ok().and_then(|p| p.to_str()) { + Some(s) => s, + None => continue, + }; + if file_name.starts_with('.') { + continue; + } + match entry.file_type() { + Ok(ft) if ft.is_file() => { + push_mention_path(&mut out, &mut seen, &mut total_added, file_name.to_string()); + } + _ => {} + } + } + } + + // 2) If still under cap, add top-level non-hidden directories (with trailing '/'). + if total_added < max_total { + if let Ok(rd) = fs::read_dir(cwd) { + for entry in rd.flatten() { + if total_added >= max_total { + break; + } + let path = entry.path(); + let file_name = match path.strip_prefix(cwd).ok().and_then(|p| p.to_str()) { + Some(s) => s, + None => continue, + }; + if file_name.starts_with('.') { + continue; + } + if let Ok(ft) = entry.file_type() { + if ft.is_dir() { + push_mention_path( + &mut out, + &mut seen, + &mut total_added, + format!("{file_name}/"), + ); + } + } + } + } + } + + if total_added > max_total { + out.truncate(max_total); + } + out +} + +/// Insert a suggestion if not seen yet; updates `total_added` and returns true when inserted. +fn push_mention_path( + out: &mut Vec, + seen: &mut std::collections::HashSet, + total_added: &mut usize, + rel: String, +) { + if seen.insert(rel.clone()) { + out.push(file_search::FileMatch { + score: 0, + path: rel, + indices: None, + }); + *total_added += 1; + } +}