# PR #2115: feat: render @-mention suggestions for empty queries - URL: https://github.com/openai/codex/pull/2115 - Author: ae-openai - Created: 2025-08-10 04:06:15 UTC - Updated: 2025-08-19 17:38:19 UTC - Changes: +153/-48, Files changed: 5, Commits: 3 ## Description - This is more helpful in helping users understand how the feature works. ## Full Diff ```diff diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d12eb76e8d..2b31942e64 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -485,9 +485,10 @@ impl App<'_> { } } AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } + self.file_search.on_user_query(query); + } + AppEvent::StopFileSearch => { + self.file_search.reset(); } AppEvent::FileSearchResult { query, matches } => { if let AppState::Chat { widget } = &mut self.app_state { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1afffd756a..3e77185db7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -51,6 +51,10 @@ pub(crate) enum AppEvent { matches: Vec, }, + /// Stop or reset any pending/active file search in the manager. + /// Used when the user cancels the `@` popup or completes a mention. + StopFileSearch, + /// Result of computing a `/diff` command. DiffResult(String), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 71483f0495..4a02219cca 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -328,6 +328,7 @@ impl ChatComposer { self.dismissed_file_popup_token = Some(tok.to_string()); } self.active_popup = ActivePopup::None; + self.app_event_tx.send(AppEvent::StopFileSearch); (InputResult::None, true) } KeyEvent { @@ -343,6 +344,7 @@ impl ChatComposer { // Drop popup borrow before using self mutably again. self.insert_selected_path(&sel_path); self.active_popup = ActivePopup::None; + self.app_event_tx.send(AppEvent::StopFileSearch); return (InputResult::None, true); } (InputResult::None, false) @@ -594,6 +596,8 @@ impl ChatComposer { None => { self.active_popup = ActivePopup::None; self.dismissed_file_popup_token = None; + // No active @token under cursor; stop any search. + self.app_event_tx.send(AppEvent::StopFileSearch); return; } }; @@ -603,26 +607,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..829246a8c3 100644 --- a/codex-rs/tui/src/file_search.rs +++ b/codex-rs/tui/src/file_search.rs @@ -51,7 +51,7 @@ pub(crate) struct FileSearchManager { struct SearchState { /// Latest query typed by user (updated every keystroke). - latest_query: String, + latest_query: Option, /// true if a search is currently scheduled. is_search_scheduled: bool, @@ -69,7 +69,7 @@ impl FileSearchManager { pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self { Self { state: Arc::new(Mutex::new(SearchState { - latest_query: String::new(), + latest_query: None, is_search_scheduled: false, active_search: None, })), @@ -83,14 +83,13 @@ impl FileSearchManager { { #[expect(clippy::unwrap_used)] let mut st = self.state.lock().unwrap(); - if query == st.latest_query { - // No change, nothing to do. + // If the query hasn't changed, nothing to do. + if st.latest_query.as_deref() == Some(query.as_str()) { return; } // Update latest query. - st.latest_query.clear(); - st.latest_query.push_str(&query); + st.latest_query = Some(query.clone()); // If there is an in-flight search that is definitely obsolete, // cancel it now. @@ -131,30 +130,59 @@ impl FileSearchManager { // 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 = { + let latest_query_opt = { #[expect(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, - ); + let Some(query) = latest_query_opt else { + return; + }; + + if query.is_empty() { + // Quick, synchronous top-level suggestions for empty query. + let max_total = MAX_FILE_SEARCH_RESULTS.get(); + let matches = collect_top_level_suggestions(&search_dir, max_total); + tx_clone.send(AppEvent::FileSearchResult { query, matches }); + } else { + // Full-text file search for non-empty query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + { + #[expect(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + } + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + } }); } + /// Reset any scheduled or active search and clear the last query. + pub fn reset(&self) { + #[expect(clippy::unwrap_used)] + let mut st = self.state.lock().unwrap(); + st.latest_query = None; + st.is_search_scheduled = false; + if let Some(active) = &st.active_search { + active.cancellation_token.store(true, Ordering::Relaxed); + } + st.active_search = None; + } + fn spawn_file_search( query: String, search_dir: PathBuf, @@ -196,3 +224,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; + } +} ``` ## Review Comments ### codex-rs/tui/src/file_search.rs - Created: 2025-08-10 05:59:05 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2265128082 ```diff @@ -86,6 +86,23 @@ impl FileSearchManager { { #[allow(clippy::unwrap_used)] let mut st = self.state.lock().unwrap(); + // If the query is empty, build quick suggestions immediately and return. ``` > Do not take the lock until after the `query.is_empty()` check. - Created: 2025-08-10 05:59:36 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2265128187 ```diff @@ -86,6 +86,23 @@ impl FileSearchManager { { #[allow(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; + } + ``` > There is quite a bit of state management below that appear to be ignored by this PR, which concerns me. - Created: 2025-08-19 17:19:38 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285874797 ```diff @@ -51,7 +51,7 @@ pub(crate) struct FileSearchManager { struct SearchState { /// Latest query typed by user (updated every keystroke). - latest_query: String, + latest_query: Option, ``` > I don't love this change because now `None` and `Some("")` are two seemingly different states? > > Just to make sure I understand, should `None` interpreted as, "popup has never been shown"? If so, maybe we should have a separate `bool` for that, which I think is a bit clearer. - Created: 2025-08-19 17:22:40 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285881101 ```diff @@ -131,30 +130,59 @@ impl FileSearchManager { // 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 = { + let latest_query_opt = { #[expect(clippy::unwrap_used)] let mut st = state.lock().unwrap(); - let query = st.latest_query.clone(); + let q = st.latest_query.clone(); st.is_search_scheduled = false; - st.active_search = Some(ActiveSearch { - query: query.clone(), - cancellation_token: token, - }); - query + q }; - FileSearchManager::spawn_file_search( - query, - search_dir, - tx_clone, - cancellation_token, - state, - ); + let Some(query) = latest_query_opt else { + return; + }; + + if query.is_empty() { + // Quick, synchronous top-level suggestions for empty query. + let max_total = MAX_FILE_SEARCH_RESULTS.get(); + let matches = collect_top_level_suggestions(&search_dir, max_total); + tx_clone.send(AppEvent::FileSearchResult { query, matches }); + } else { + // Full-text file search for non-empty query. + let cancellation_token = Arc::new(AtomicBool::new(false)); + let token = cancellation_token.clone(); + { + #[expect(clippy::unwrap_used)] + let mut st = state.lock().unwrap(); + st.active_search = Some(ActiveSearch { + query: query.clone(), + cancellation_token: token, + }); + } + + FileSearchManager::spawn_file_search( + query, + search_dir, + tx_clone, + cancellation_token, + state, + ); + } }); } + /// Reset any scheduled or active search and clear the last query. ``` > Why is this necessary now? - Created: 2025-08-19 17:23:34 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285883038 ```diff @@ -196,3 +224,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; ``` > imports at the top, please - Created: 2025-08-19 17:23:52 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285883601 ```diff @@ -196,3 +224,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) { ``` > Please swap your two-letter variable names for more descriptive ones. - Created: 2025-08-19 17:25:21 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285886684 ```diff @@ -196,3 +224,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, ``` > ```suggestion > seen: &mut std::collections::HashSet, > ``` > ```suggestion > seen: &mut HashSet, > ``` - Created: 2025-08-19 17:26:34 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285889018 ```diff @@ -131,30 +130,59 @@ impl FileSearchManager { // 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 = { + let latest_query_opt = { #[expect(clippy::unwrap_used)] let mut st = state.lock().unwrap(); - let query = st.latest_query.clone(); + let q = st.latest_query.clone(); st.is_search_scheduled = false; - st.active_search = Some(ActiveSearch { - query: query.clone(), - cancellation_token: token, - }); - query + q }; - FileSearchManager::spawn_file_search( - query, - search_dir, - tx_clone, - cancellation_token, - state, - ); + let Some(query) = latest_query_opt else { + return; + }; + + if query.is_empty() { + // Quick, synchronous top-level suggestions for empty query. + let max_total = MAX_FILE_SEARCH_RESULTS.get(); + let matches = collect_top_level_suggestions(&search_dir, max_total); + tx_clone.send(AppEvent::FileSearchResult { query, matches }); ``` > What's the downside of returning `FileSearchManager` here so the two codepaths are more similar? - Created: 2025-08-19 17:32:25 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285900878 ```diff @@ -51,7 +51,7 @@ pub(crate) struct FileSearchManager { struct SearchState { /// Latest query typed by user (updated every keystroke). - latest_query: String, + latest_query: Option, ``` > That sounds like `active_search`? - Created: 2025-08-19 17:38:19 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285912428 ```diff @@ -83,14 +83,13 @@ impl FileSearchManager { { #[expect(clippy::unwrap_used)] let mut st = self.state.lock().unwrap(); - if query == st.latest_query { - // No change, nothing to do. + // If the query hasn't changed, nothing to do. + if st.latest_query.as_deref() == Some(query.as_str()) { ``` > Maybe this is changed to: > > ```suggestion > if query == st.latest_query || query.is_empty() > ```