mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
24 KiB
24 KiB
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 --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
- Created: 2025-08-10 05:59:05 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2265128082
@@ -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
@@ -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
@@ -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
NoneandSome("")are two seemingly different states?Just to make sure I understand, should
Noneinterpreted as, "popup has never been shown"? If so, maybe we should have a separateboolfor 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
@@ -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
@@ -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
- Created: 2025-08-19 17:23:52 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285883601
@@ -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.
- Created: 2025-08-19 17:25:21 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285886684
@@ -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>,
- Created: 2025-08-19 17:26:34 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285889018
@@ -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
FileSearchManagerhere 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
@@ -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?
- Created: 2025-08-19 17:38:19 UTC | Link: https://github.com/openai/codex/pull/2115#discussion_r2285912428
@@ -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()