From 2bb8144d41e984e596233faf5ce5c233a7d6875c Mon Sep 17 00:00:00 2001 From: Taylor McIntyre Date: Thu, 9 Apr 2026 13:43:00 -0700 Subject: [PATCH] Integrate # command with sessions --- codex-rs/tui/src/app.rs | 37 ++++++++ codex-rs/tui/src/app_event.rs | 6 ++ codex-rs/tui/src/app_server_session.rs | 1 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 87 +++++++++++-------- 4 files changed, 92 insertions(+), 39 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b0dd27795c..2cda577d47 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4501,6 +4501,43 @@ impl App { self.chat_widget.set_recent_session_mentions(Vec::new()); } }, + AppEvent::RememberThread { + source_thread_id, + source_thread_title, + } => { + let Some(thread_id) = self.current_displayed_thread_id() else { + self.chat_widget.add_error_message( + "No active thread is available for remembered context.".to_string(), + ); + return Ok(AppRunControl::Continue); + }; + let Ok(source_thread_id) = ThreadId::from_string(&source_thread_id) else { + self.chat_widget + .add_error_message("Selected session is not a valid thread.".to_string()); + return Ok(AppRunControl::Continue); + }; + + match app_server + .thread_remember(thread_id, vec![source_thread_id]) + .await + { + Ok(response) => { + let title = source_thread_title.trim(); + let message = + if response.remembered_thread_ids.len() == 1 && !title.is_empty() { + format!("Remembered context from {title}.") + } else { + "Remembered context from a previous session.".to_string() + }; + self.chat_widget.add_info_message(message, /*hint*/ None); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to remember previous session: {err}" + )); + } + } + } AppEvent::PluginInstallAuthAdvance { refresh_connectors } => { if refresh_connectors { self.chat_widget.refresh_connectors(/*force_refetch*/ true); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bc4801c2cd..132ae1fc71 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -219,6 +219,12 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Copy hidden context from a previous thread into the current thread. + RememberThread { + source_thread_id: String, + source_thread_title: String, + }, + /// Fetch plugin marketplace state for the provided working directory. FetchPluginsList { cwd: PathBuf, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index b5ac0adc3a..9fe941acca 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -420,7 +420,6 @@ impl AppServerSession { Ok(response.thread) } - #[allow(dead_code)] // The picker wiring will call this once the /remember UI lands. pub(crate) async fn thread_remember( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 690fc61f3d..763c09cfb8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1562,6 +1562,7 @@ impl ChatComposer { }; let mut selected_mention: Option<(String, Option)> = None; + let mut selected_recent_session: Option<(String, String)> = None; let mut close_popup = false; let result = match key_event { @@ -1606,7 +1607,15 @@ impl ChatComposer { .. } => { if let Some(mention) = popup.selected_mention() { - selected_mention = Some((mention.insert_text.clone(), mention.path.clone())); + if mention.insert_text.starts_with('#') + && let Some(source_thread_id) = mention.path.clone() + { + selected_recent_session = + Some((source_thread_id, mention.display_name.clone())); + } else { + selected_mention = + Some((mention.insert_text.clone(), mention.path.clone())); + } } close_popup = true; (InputResult::None, true) @@ -1615,7 +1624,16 @@ impl ChatComposer { }; if close_popup { - if let Some((insert_text, path)) = selected_mention { + if let Some((source_thread_id, source_thread_title)) = selected_recent_session { + let token_range = self.active_token_range(); + let start_idx = token_range.start; + self.textarea.replace_range(token_range, ""); + self.textarea.set_cursor(start_idx); + self.app_event_tx.send(AppEvent::RememberThread { + source_thread_id, + source_thread_title, + }); + } else if let Some((insert_text, path)) = selected_mention { self.insert_selected_mention(&insert_text, path.as_deref()); } self.active_popup = ActivePopup::None; @@ -1911,21 +1929,15 @@ impl ChatComposer { .map(|query| Self::dismissed_popup_token('#', &query)) } - /// Replace the active `@token` (the one under the cursor) with `path`. - /// - /// The algorithm mirrors `current_at_token` so replacement works no matter - /// where the cursor is within the token and regardless of how many - /// `@tokens` exist in the line. - fn insert_selected_path(&mut self, path: &str) { + /// Return the whitespace-delimited token under the cursor. + fn active_token_range(&self) -> Range { let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); let before_cursor = &text[..safe_cursor]; let after_cursor = &text[safe_cursor..]; - // Determine token boundaries. let start_idx = before_cursor .char_indices() .rfind(|(_, c)| c.is_whitespace()) @@ -1939,6 +1951,15 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; + start_idx..end_idx + } + + /// Replace the active `@token` (the one under the cursor) with `path`. + /// + /// The algorithm mirrors `current_at_token` so replacement works no matter + /// where the cursor is within the token and regardless of how many + /// `@tokens` exist in the line. + fn insert_selected_path(&mut self, path: &str) { // If the path contains whitespace, wrap it in double quotes so the // local prompt arg parser treats it as a single argument. Avoid adding // quotes when the path already contains one to keep behavior simple. @@ -1951,35 +1972,19 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. + let token_range = self.active_token_range(); + let start_idx = token_range.start; self.textarea - .replace_range(start_idx..end_idx, &format!("{inserted} ")); + .replace_range(token_range, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.textarea.cursor(); - let text = self.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - // Remove the active token and insert the selected mention as an atomic element. - self.textarea.replace_range(start_idx..end_idx, ""); + let token_range = self.active_token_range(); + let start_idx = token_range.start; + self.textarea.replace_range(token_range, ""); self.textarea.set_cursor(start_idx); let id = self.textarea.insert_element(insert_text); @@ -5053,8 +5058,8 @@ mod tests { } #[test] - fn hash_item_popup_inserts_selected_session_id() { - let (tx, _rx) = unbounded_channel::(); + fn hash_item_popup_remembers_selected_session() { + let (tx, mut rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( /*has_input_focus*/ true, @@ -5084,11 +5089,17 @@ mod tests { let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - composer.current_text(), - "#11111111-1111-1111-1111-111111111111 " - ); + assert_eq!(composer.current_text(), ""); assert!(matches!(composer.active_popup, ActivePopup::None)); + let event = rx.try_recv().expect("expected remember event"); + assert!(matches!( + event, + AppEvent::RememberThread { + source_thread_id, + source_thread_title, + } if source_thread_id == "11111111-1111-1111-1111-111111111111" + && source_thread_title == "Answer an open question" + )); } #[test]