From 4e29c4afe4b26f444d062fe22d7e590a9c499eff Mon Sep 17 00:00:00 2001 From: easong-openai Date: Thu, 7 Aug 2025 00:41:48 -0700 Subject: [PATCH] Add a UI hint when you press @ (#1903) This will make @ more discoverable (even though it is currently not super useful, IMO it should be used to bring files into context from outside CWD) --------- Co-authored-by: Gabriel Peal --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 44 ++++++++++++++----- .../tui/src/bottom_pane/file_search_popup.rs | 11 +++++ 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ad3b4f33..3ecb4d0f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -416,7 +416,9 @@ impl App<'_> { } } AppEvent::StartFileSearch(query) => { - self.file_search.on_user_query(query); + if !query.is_empty() { + 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 5d877253..2feab9a2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -331,8 +331,9 @@ impl ChatComposer { /// - 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. + /// - If the token under the cursor starts with `@`, that token is + /// returned without the leading `@`. This includes the case where the + /// token is just "@" (empty query), which is used to trigger a UI hint fn current_at_token(textarea: &TextArea) -> Option { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -403,14 +404,20 @@ impl ChatComposer { }; let left_at = token_left - .filter(|t| t.starts_with('@') && t.len() > 1) + .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); let right_at = token_right - .filter(|t| t.starts_with('@') && t.len() > 1) + .filter(|t| t.starts_with('@')) .map(|t| t[1..].to_string()); if at_whitespace { - return right_at.or(left_at); + if right_at.is_some() { + return right_at; + } + if token_left.is_some_and(|t| t == "@") { + return None; + } + return left_at; } if after_cursor.starts_with('@') { return right_at.or(left_at); @@ -607,16 +614,26 @@ impl ChatComposer { return; } - self.app_event_tx - .send(AppEvent::StartFileSearch(query.clone())); + if !query.is_empty() { + self.app_event_tx + .send(AppEvent::StartFileSearch(query.clone())); + } match &mut self.active_popup { ActivePopup::File(popup) => { - popup.set_query(&query); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } } _ => { let mut popup = FileSearchPopup::new(); - popup.set_query(&query); + if query.is_empty() { + popup.set_empty_prompt(); + } else { + popup.set_query(&query); + } self.active_popup = ActivePopup::File(popup); } } @@ -773,7 +790,12 @@ mod tests { ("@👍", 2, Some("👍".to_string()), "Emoji token"), // Invalid cases (should return None) ("hello", 2, None, "No @ symbol"), - ("@", 1, None, "Only @ symbol"), + ( + "@", + 1, + Some("".to_string()), + "Only @ symbol triggers empty query", + ), ("@ hello", 2, None, "@ followed by space"), ("test @ world", 6, None, "@ with spaces around"), ]; @@ -807,7 +829,7 @@ mod tests { "Second token", ), // Edge cases - ("@", 0, None, "Only @ symbol"), + ("@", 0, Some("".to_string()), "Only @ symbol"), ("@a", 2, Some("a".to_string()), "Single character after @"), ("", 0, None, "Empty input"), ]; 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 c30a24f9..a811a22a 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -54,6 +54,17 @@ 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) {