Files
codex/prs/bolinfest/PR-2115.md
2025-09-02 15:17:45 -07:00

24 KiB

PR #2115: feat: render @-mention suggestions for empty queries

Description

  • This is more helpful in helping users understand how the feature works.

Full 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<FileMatch>,
     },
 
+    /// 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<FileMatch>) {
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<String>,
 
     /// 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<file_search::FileMatch> {
+    use std::collections::HashSet;
+    use std::fs;
+
+    let mut seen: HashSet<String> = HashSet::new();
+    let mut out: Vec<file_search::FileMatch> = 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<file_search::FileMatch>,
+    seen: &mut std::collections::HashSet<String>,
+    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

@@ -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.

@@ -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.

@@ -51,7 +51,7 @@ pub(crate) struct FileSearchManager {
 
 struct SearchState {
     /// Latest query typed by user (updated every keystroke).
-    latest_query: String,
+    latest_query: Option<String>,

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.

@@ -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?

@@ -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<file_search::FileMatch> {
+    use std::collections::HashSet;

imports at the top, please

@@ -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<file_search::FileMatch> {
+    use std::collections::HashSet;
+    use std::fs;
+
+    let mut seen: HashSet<String> = HashSet::new();
+    let mut out: Vec<file_search::FileMatch> = 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.

@@ -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<file_search::FileMatch> {
+    use std::collections::HashSet;
+    use std::fs;
+
+    let mut seen: HashSet<String> = HashSet::new();
+    let mut out: Vec<file_search::FileMatch> = 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<file_search::FileMatch>,
+    seen: &mut std::collections::HashSet<String>,
    seen: &mut std::collections::HashSet<String>,
    seen: &mut HashSet<String>,
@@ -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?

@@ -51,7 +51,7 @@ pub(crate) struct FileSearchManager {
 
 struct SearchState {
     /// Latest query typed by user (updated every keystroke).
-    latest_query: String,
+    latest_query: Option<String>,

That sounds like active_search?

@@ -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:

            if query == st.latest_query || query.is_empty()