From 4e4767ffa763ec0d0a19e04cf8ff0163a987297b Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sun, 8 Nov 2026 19:23:06 +0000 Subject: [PATCH] Search task history from the command center (#60286) ## Why Command center search only filtered tasks already loaded for browsing, leaving older tasks undiscoverable without manually loading more history. ## What changed - Scan history on the active backend for case-insensitive matches in task names, previews, and working directories, using a separate paging cursor for each query. - Add loading, retry, and `Show more matches` states. Cancel superseded searches and ignore stale results. - Keep history matches separate from browse results and restore the previous selection when leaving search. - Preserve the original task preview for search while displaying the latest prompt in task details. ## Testing Extend discovery and command center tests to cover older history matches, fresh queries after browse pagination is exhausted, selection restoration, actions on search results, loading and retry states, and separate original previews and latest prompts. GitOrigin-RevId: 9864ee006a14bd232e9cfd7ab2ee099c84667597 --- codex-rs/tui/src/app/agent_center/mod.rs | 2 +- codex-rs/tui/src/app/agent_center/render.rs | 5 +- codex-rs/tui/src/app/agent_center/rows.rs | 13 +- codex-rs/tui/src/app/agent_center_tests.rs | 12 +- codex-rs/tui/src/app/agents_overview.rs | 65 +++++++++- .../tui/src/app/agents_overview_actions.rs | 1 + .../src/app/agents_overview_actions_tests.rs | 18 ++- .../tui/src/app/agents_overview_details.rs | 13 +- .../tui/src/app/agents_overview_discovery.rs | 118 +++++++++++++++++- .../app/agents_overview_discovery_tests.rs | 104 +++++++++++++-- .../tui/src/app/agents_overview_grouping.rs | 17 +-- codex-rs/tui/src/app/agents_overview_tests.rs | 14 ++- .../tui/src/app/agents_overview_threads.rs | 28 +++-- codex-rs/tui/src/app/agents_overview_usage.rs | 4 +- .../src/app/agents_overview_usage_tests.rs | 2 +- codex-rs/tui/src/app/agents_overview_view.rs | 87 ++++++++++++- codex-rs/tui/src/app/backend_selection.rs | 2 + codex-rs/tui/src/app/event_dispatch.rs | 5 + codex-rs/tui/src/app/reconnect.rs | 1 + ...and_center__live_center_search_active.snap | 2 +- .../app/tests/navigation_reconnect_tests.rs | 2 +- codex-rs/tui/src/app_event.rs | 9 +- 22 files changed, 465 insertions(+), 59 deletions(-) diff --git a/codex-rs/tui/src/app/agent_center/mod.rs b/codex-rs/tui/src/app/agent_center/mod.rs index 70d153216a..770eefc580 100644 --- a/codex-rs/tui/src/app/agent_center/mod.rs +++ b/codex-rs/tui/src/app/agent_center/mod.rs @@ -20,7 +20,7 @@ pub(super) const TASK_FILTERS: &[(&str, Option)] = &[ impl AgentsOverviewView { pub(in crate::app::agents_overview_view) fn selectable_indices(&self) -> Vec { let mut indices = self.visible_indices(); - if self.state().has_more { + if self.state().more_available() { indices.push(usize::MAX); } indices diff --git a/codex-rs/tui/src/app/agent_center/render.rs b/codex-rs/tui/src/app/agent_center/render.rs index 7f27a66fdd..4959c7ec62 100644 --- a/codex-rs/tui/src/app/agent_center/render.rs +++ b/codex-rs/tui/src/app/agent_center/render.rs @@ -154,12 +154,14 @@ impl Renderable for AgentsOverviewView { inset(layout.header), buf, ); + let history = state.history.as_ref(); let labels = TASK_FILTERS .iter() .map(|(label, group)| { let count = self .rows .iter() + .filter(|row| state.includes_row(row)) .filter(|row| group.is_none_or(|group| group == row.group)) .count(); format!("{label} {count}") @@ -229,7 +231,8 @@ impl Renderable for AgentsOverviewView { }) }) .or_else(|| { - (state.refresh_failed && !state.loading).then(|| "Error loading tasks".to_owned()) + let failed = state.refresh_failed || history.is_some_and(|s| s.failed); + (failed && !state.loading).then(|| "Error loading tasks".to_owned()) }) .or_else(|| state.server_version_notice.clone()); if let Some(notice) = notice.filter(|_| !state.help && state.key_chord_hint.is_none()) { diff --git a/codex-rs/tui/src/app/agent_center/rows.rs b/codex-rs/tui/src/app/agent_center/rows.rs index 629694e6a9..c630e35038 100644 --- a/codex-rs/tui/src/app/agent_center/rows.rs +++ b/codex-rs/tui/src/app/agent_center/rows.rs @@ -50,6 +50,8 @@ impl AgentsOverviewView { line( if state.connection_notice.is_some() { "Reconnecting…" + } else if state.history.is_some() { + "No matching tasks" } else if state.loading && self.rows.is_empty() { "Loading tasks…" } else if state.refresh_failed { @@ -121,7 +123,15 @@ impl AgentsOverviewView { }, style, ); - let label = if state.loading { + let label = if let Some(search) = &state.history { + if search.loading { + "Searching history…" + } else if search.failed { + "Show more (retry)" + } else { + "Show more matches" + } + } else if state.loading { "Loading more…" } else if state.refresh_failed { "Show more (retry)" @@ -160,6 +170,7 @@ impl AgentsOverviewView { }) .count(); let total = (0..self.rows.len()) + .filter(|&i| state.includes_row(&self.rows[i])) .filter(|&candidate| self.same_group(state.grouping, candidate, index)) .count(); let count = if count == total { diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index b0a34b1460..c1ffef0757 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -4,7 +4,7 @@ use super::*; use assert_matches::assert_matches; use pretty_assertions::assert_eq; -fn screen(view: &AgentsOverviewView, width: u16, height: u16) -> String { +pub(super) fn screen(view: &AgentsOverviewView, width: u16, height: u16) -> String { let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); let mut buf = ratatui::buffer::Buffer::empty(area); view.render(area, &mut buf); @@ -819,6 +819,9 @@ async fn backspace_edits_search_and_rename_without_deleting_tasks() { view.handle_paste("!".into()); view.handle_key_event(KeyCode::Backspace.into()); assert_eq!(screen(&view, /*width*/ 88, /*height*/ 16), initial); + if key == '/' { + assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(..))); + } assert!(rx.try_recv().is_err()); view.handle_key_event(KeyCode::Esc.into()); } @@ -895,10 +898,15 @@ async fn live_center_show_more_navigation_search_and_loading() { } view.handle_key_event(KeyCode::Char('/').into()); view.handle_paste("Older task".into()); + let id = assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(id, _)) => id); + insta::assert_snapshot!(screen(&view, /*width*/ 80, /*height*/ 12).lines().nth(6).unwrap(), @" › Searching history…"); + app.apply_agents_overview_search_page(id, Vec::new(), Default::default(), /*ok*/ false); insta::assert_snapshot!( "live_center_show_more_search_retry", screen(&view, /*width*/ 80, /*height*/ 12) ); view.handle_key_event(KeyCode::Enter.into()); - assert_matches!(rx.try_recv(), Ok(AppEvent::ShowMoreAgentsOverview)); + let id = assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(id, _)) => id); + app.apply_agents_overview_search_page(id, Vec::new(), Default::default(), /*ok*/ true); + insta::assert_snapshot!(screen(&view, /*width*/ 80, /*height*/ 12).lines().nth(6).unwrap(), @" › Show more matches"); } diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 6e36418a73..6ef505771a 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -38,6 +38,9 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; pub(super) struct AgentsOverviewState { /// Missing metadata records a local resume until the next metadata refresh. pub(super) threads: HashMap>, + /// Identifies the query that populated the cached search results. + pub(super) search_id: Uuid, + pub(super) search_matches: HashMap, /// Lifecycle removals take precedence over delayed tool registration responses. pub(super) removed_threads: HashSet, /// Local visibility only; activity and metadata refreshes never reveal hidden roots. @@ -173,7 +176,8 @@ impl App { if self.agents_overview.removed_threads.contains(&thread_id) { continue; } - if let Some((mut thread, unread)) = thread { + if let Some((mut thread, unread, latest_prompt)) = thread { + self.agents_overview.search_matches.remove(&thread_id); if unread { self.agents_overview.unread.insert(thread_id); } else { @@ -197,6 +201,11 @@ impl App { self.agents_overview.refill_count = self.agents_overview.refill_count.saturating_sub(1); } + self.agents_overview + .activity + .entry(thread_id) + .or_default() + .latest_prompt = latest_prompt; thread.turns.clear(); self.agents_overview.threads.insert(thread_id, Some(thread)); } else { @@ -216,6 +225,9 @@ impl App { && let Ok(thread_id) = ThreadId::from_string(&reverted.thread_id) { // Discard stale read results without clearing activity received after the revert. + if let Some(activity) = self.agents_overview.activity.get_mut(&thread_id) { + activity.latest_prompt = None; + } self.agents_overview.last_messages.remove(&thread_id); if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) { usage.tokens = None; @@ -268,6 +280,18 @@ impl App { .cloned() .collect(); let view = self.agents_overview_view(threads, selected_thread_id); + let matches = &mut self.agents_overview.search_matches; + if !matches.is_empty() { + let visible: HashSet<_> = view + .visible_indices() + .into_iter() + .map(|i| view.rows[i].thread_id) + .collect(); + matches.retain(|id, _| { + visible.contains(id) || self.agents_overview.hidden_threads.contains(id) + }); + } + view.load_search_matches(/*additional*/ 0); self.agents_overview.visible_thread_ids = view.thread_ids(); let next_thread_id = view .selected_index() @@ -304,6 +328,35 @@ impl App { mut threads: Vec, selected_thread_id: Option, ) -> AgentsOverviewView { + { + let overview = &self.agents_overview; + let mut state = overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.vim_enabled = self.chat_widget.composer_is_vim_enabled(); + if state + .history + .as_ref() + .is_some_and(|search| search.id == overview.search_id) + { + let query = state.search.to_lowercase(); + threads.extend( + overview + .search_matches + .values() + .filter(|thread| { + super::agents_overview_discovery::matches_query(thread, &query) + }) + .cloned(), + ); + } else if let Some(target) = state + .rename_target + .and_then(|id| overview.search_matches.get(&id)) + { + threads.push(target.clone()); + } + } threads.retain(|thread| !thread.ephemeral); for thread in &mut threads { if thread.parent_thread_id.is_none() @@ -349,6 +402,11 @@ impl App { continue; } rows.push(AgentsOverviewRow { + search_id: self + .agents_overview + .search_matches + .contains_key(&thread_id) + .then_some(self.agents_overview.search_id), unread: self.agents_overview.unread.contains(&thread_id), details: self.agents_overview_details(root, &children), thread: root.clone(), @@ -359,11 +417,6 @@ impl App { }); } - self.agents_overview - .view_state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .vim_enabled = self.chat_widget.composer_is_vim_enabled(); let mut view = AgentsOverviewView::new( rows, selected_thread_id, diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index b9cae381d9..a5402e6029 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -190,6 +190,7 @@ impl App { .threads .get(&thread_id) .and_then(Option::as_ref) + .or_else(|| self.agents_overview.search_matches.get(&thread_id)) else { return; }; diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index 1237e9d1e2..b56ec193e8 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -137,9 +137,11 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { ); view.handle_key_event(KeyCode::Esc.into()); view.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); - for character in "Target".chars() { - view.handle_key_event(KeyCode::Char(character).into()); - } + view.handle_paste("Target".into()); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::SearchAgentsOverview(..)) + )); for (key, expected) in [ (5, AgentsOverviewAction::Archive), (6, AgentsOverviewAction::Delete), @@ -664,7 +666,7 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum request_id, Ok(AgentsOverviewThreadRefresh { cleared_unread: Vec::new(), - threads: HashMap::from([(id, Some((thread.clone(), false)))]), + threads: HashMap::from([(id, Some((thread.clone(), false, None)))]), last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, @@ -948,7 +950,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb cleared_unread: Vec::new(), threads: stale_threads .into_iter() - .map(|(id, thread)| (id, thread.map(|thread| (thread, false)))) + .map(|(id, thread)| (id, thread.map(|thread| (thread, false, None)))) .collect(), last_messages: HashMap::new(), recent_seed_complete: true, @@ -1126,6 +1128,12 @@ async fn fork_shortcut_respects_metadata_editing() { for editor in ['r', '/'] { view.handle_key_event(KeyCode::Char(editor).into()); view.handle_key_event(KeyCode::Char('f').into()); + if editor == '/' { + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::SearchAgentsOverview(..)) + )); + } assert!(rx.try_recv().is_err()); view.handle_key_event(KeyCode::Esc.into()); } diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs index 9629ea829f..04aa6824b9 100644 --- a/codex-rs/tui/src/app/agents_overview_details.rs +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -35,6 +35,7 @@ pub(super) fn preview_markdown(text: &str) -> String { #[derive(Clone, Default)] pub(super) struct AgentsOverviewDetails { + pub(super) latest_prompt: Option, pub(super) lines: Vec>, pub(super) usage_lines: Vec>, pub(super) last_message: Option<(String, AbsolutePathBuf)>, @@ -50,6 +51,7 @@ pub(super) fn preview_text(text: &str) -> String { #[derive(Default)] pub(super) struct AgentsOverviewActivity { + pub(super) latest_prompt: Option, reasoning: Option, last_message: Option, } @@ -219,10 +221,17 @@ impl App { .or_else(|| self.agents_overview.last_messages.get(&thread_id)) .filter(|message| !message.trim().is_empty()) .map(|message| (message.clone(), source.cwd.clone())); + let root_id = ThreadId::from_string(&root.id).ok(); AgentsOverviewDetails { + latest_prompt: root_id.and_then(|id| { + self.agents_overview + .activity + .get(&id)? + .latest_prompt + .clone() + }), lines, - usage_lines: ThreadId::from_string(&root.id) - .ok() + usage_lines: root_id .and_then(|id| self.agents_overview.usage.get(&id)) .map(|usage| { super::agents_overview_usage::usage_lines( diff --git a/codex-rs/tui/src/app/agents_overview_discovery.rs b/codex-rs/tui/src/app/agents_overview_discovery.rs index 653d915d8d..8c951fbe2c 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery.rs @@ -1,6 +1,8 @@ //! Demand-driven history discovery. Each source retains its cursor and unshown rows. -//! One activation requests at most one page per source, then publishes up to ten tasks. +//! Each new query scans the active backend from the start with its own paging cursor. +use super::App; +use crate::app_event::AppEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ClientRequest; @@ -14,6 +16,8 @@ use codex_app_server_protocol::ThreadSourceKind; use codex_protocol::protocol::SubAgentSource; use codex_state::PINNED_THREAD_SECTION_ID; use std::collections::VecDeque; +use std::sync::PoisonError; +use tokio_util::task::AbortOnDropHandle; use uuid::Uuid; const PAGE_SIZE: u32 = 10; @@ -110,6 +114,7 @@ struct SourcePage { rows: VecDeque<(Thread, bool)>, exhausted: bool, sort_key: ThreadSortKey, + page_size: u32, } impl Default for SourcePage { @@ -119,6 +124,7 @@ impl Default for SourcePage { rows: VecDeque::new(), exhausted: false, sort_key: ThreadSortKey::RecencyAt, + page_size: PAGE_SIZE, } } } @@ -192,7 +198,7 @@ impl SourcePage { attached_to_thread_id: None, originators: None, cursor: self.cursor.clone(), - limit: Some(PAGE_SIZE), + limit: Some(self.page_size), sort_key: Some(self.sort_key), sort_direction: None, model_providers: Some(Vec::new()), @@ -218,6 +224,9 @@ impl SourcePage { self.cursor = None; } Ok(page) => { + if page.next_cursor.is_some() && page.next_cursor == self.cursor { + return false; + } self.exhausted = page.next_cursor.is_none(); self.cursor = page.next_cursor.clone(); self.rows.extend( @@ -233,3 +242,108 @@ impl SourcePage { } } } + +#[derive(Default)] +pub(super) struct HistorySearch { + pub(super) id: Uuid, + pub(super) cursor: AgentsOverviewDiscovery, + /// Requested number of matches visible under the active status filter. + pub(super) target: usize, + pub(super) loading: bool, + pub(super) failed: bool, + pub(super) task: Option>, +} + +pub(super) fn matches_query(thread: &Thread, query: &str) -> bool { + let name = thread.name.as_deref().unwrap_or_default(); + let cwd = thread.cwd.display(); + format!("{name} {} {cwd}", thread.preview) + .to_lowercase() + .contains(query) +} + +impl App { + pub(super) fn search_agents_overview( + &mut self, + handle: AppServerRequestHandle, + id: Uuid, + limit: usize, + ) { + let shared = &self.agents_overview.view_state; + let mut state = shared.lock().unwrap_or_else(PoisonError::into_inner); + let query = state.search.to_lowercase(); + let Some(search) = state.history.as_mut().filter(|search| search.id == id) else { + return; + }; + let first = self.agents_overview.search_id != id; + let mut cursor = std::mem::take(&mut search.cursor); + if first { + self.agents_overview.search_matches.clear(); + self.agents_overview.search_id = id; + } + for source in &mut cursor.sources { + source.page_size = 100; + } + if matches!(handle, AppServerRequestHandle::Cloud(_)) { + cursor.sources[1].exhausted = true; + } + let tx = self.app_event_tx.clone(); + search.task = Some(AbortOnDropHandle::new(tokio::spawn(async move { + if first { + tokio::time::sleep(std::time::Duration::from_millis(180)).await; + } + loop { + let (mut rows, ok) = cursor.next_batch(&handle, limit).await; + rows.retain(|(thread, _)| matches_query(thread, &query)); + if rows.is_empty() && ok && cursor.has_more() { + continue; + } + tx.send(AppEvent::AgentsOverviewSearchPage { + id, + rows, + cursor, + ok, + }); + return; + } + }))); + } + + pub(super) fn apply_agents_overview_search_page( + &mut self, + id: Uuid, + rows: Vec<(Thread, bool)>, + cursor: AgentsOverviewDiscovery, + ok: bool, + ) { + let overview = &mut self.agents_overview; + let mut state = overview + .view_state + .lock() + .unwrap_or_else(PoisonError::into_inner); + let Some(search) = state.history.as_mut().filter(|s| s.id == id) else { + return; + }; + for (thread, unread) in rows { + if let Ok(id) = codex_protocol::ThreadId::from_string(&thread.id) + && !overview.threads.get(&id).is_some_and(Option::is_some) + && !overview.removed_threads.contains(&id) + && !overview.hidden_threads.contains(&id) + && let std::collections::hash_map::Entry::Vacant(entry) = + overview.search_matches.entry(id) + { + entry.insert(thread); + if unread { + overview.unread.insert(id); + } else { + overview.unread.remove(&id); + } + } + } + search.cursor = cursor; + search.loading = false; + search.failed = !ok; + drop(state); + self.repaint_agents_overview(); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_discovery_tests.rs b/codex-rs/tui/src/app/agents_overview_discovery_tests.rs index 82830c7e93..826c08de0b 100644 --- a/codex-rs/tui/src/app/agents_overview_discovery_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_discovery_tests.rs @@ -1,6 +1,7 @@ //! Demand-driven discovery, cursor retry, and visible Show more behavior. use super::*; +use crate::app_event::AgentsOverviewAction; use crate::app_server_session::ThreadParamsMode; use codex_app_server_protocol::JSONRPCMessage; use futures::SinkExt; @@ -32,6 +33,9 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { if mixed_sources && (index == 0 || index >= 21) { thread.source = SessionSource::Exec; } + if index == 23 { + thread.name = None; + } thread.updated_at += 24 - index; thread.recency_at = Some(thread.updated_at); thread @@ -107,22 +111,26 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { continue; } assert_eq!(params["sortKey"], "recency_at"); - assert_eq!(params["limit"], 10); + let limit = params["limit"].as_u64().unwrap() as usize; + assert!(params["searchTerm"].is_null()); { let interactive = params["sourceKinds"] == json!([]); - let source_rows: Vec<_> = rows + let mut source_rows: Vec<_> = rows .iter() .filter(|row| matches!(row.source, SessionSource::Exec) != interactive) .collect(); + if interactive && server_show_reopened_pin.load(Ordering::SeqCst) { + source_rows.insert(0, &reopened_pin); + } let cursor = params["cursor"].as_str().unwrap_or("0").parse::()?; - if interactive { + if interactive && limit == 10 { cursors.push(cursor); } - if !mixed_sources && interactive && cursors == [0, 10] { + if limit == 10 && !mixed_sources && interactive && cursors == [0, 10] { socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32603, "message": "transient"}}).to_string().into())).await?; continue; } - let end = (cursor + 10).min(source_rows.len()); + let end = (cursor + limit).min(source_rows.len()); json!({"data": source_rows[cursor..end], "nextCursor": (end < source_rows.len()).then(|| end.to_string())}) } } @@ -133,7 +141,15 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { .unwrap(); json!({"thread": thread, "readState": {"revision": "read", "firstUnread": null}}) } + "thread/turns/list" if params["threadId"] == rows[23].id => json!({"data": [{ + "id":"latest", "status":"completed", "items":[{"type":"userMessage","id":"prompt", + "content":[{"type":"text","text":"A later prompt","textElements":[]}]}] + }], "nextCursor":null}), "thread/turns/list" => json!({"data": [], "nextCursor": null}), + "thread/archive" | "thread/delete" => { + socket.send(Message::Text(json!({"id": request.id, "error":{"code":-32603,"message":"try again"}}).to_string().into())).await?; + continue; + } method => panic!("unexpected request: {method}"), }; socket @@ -146,7 +162,7 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { } Ok::<_, color_eyre::Report>(cursors) }); - let session = AppServerSession::new( + let mut session = AppServerSession::new( crate::backend_id::BackendId::new(), crate::connect_remote_app_server(endpoint).await?, ThreadParamsMode::Remote, @@ -160,6 +176,39 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { assert_eq!(app.agents_overview.threads.len(), 10); assert_eq!(app.agents_overview.pinned_thread_ids, Some(vec![ids[1]])); assert!(app.agents_overview.discovery.has_more()); + let browse = app.agents_overview.threads.clone(); + let browse_rows = || browse.values().flatten().cloned().collect(); + let mut view = app.agents_overview_view(browse_rows(), Some(ids[4])); + view.handle_key_event(KeyCode::Char('/').into()); + view.handle_paste("TASK 23".into()); + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?; + assert_eq!(app.agents_overview.search_matches.len(), 1); + view = app.agents_overview_view(browse_rows(), /*selected_thread_id*/ None); + assert_eq!(view.thread_ids()[view.selected_index().unwrap()], ids[23]); + let action = if mixed_sources { + AgentsOverviewAction::Archive + } else { + AgentsOverviewAction::Delete + }; + Box::pin(app.run_agents_overview_action(&mut tui, &mut session, ids[23], action)).await?; + finish_overview_refresh(&mut app, &session, &mut rx).await; + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Task 23")); + view.handle_key_event(KeyCode::Esc.into()); + assert_eq!(view.thread_ids()[view.selected_index().unwrap()], ids[4]); + assert_eq!(app.agents_overview.threads, browse); + view.handle_key_event(KeyCode::Char('/').into()); + view.handle_paste("TASK 22".into()); + assert!(super::command_center::screen(&view, /*width*/ 100, /*height*/ 16).contains("All 10 ")); + finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?; + view = app.agents_overview_view(browse_rows(), /*selected_thread_id*/ None); + let rendered = super::command_center::screen(&view, /*width*/ 100, /*height*/ 16); + assert!(rendered.contains("All 11 ")); + assert!(rendered.contains("1 of 10"), "{rendered}"); // The pinned task has its own group. + view.handle_key_event(KeyCode::Esc.into()); + app.track_agents_overview_notification(&ServerNotification::ThreadArchived( ThreadArchivedNotification { thread_id: ids[0].to_string(), @@ -211,10 +260,28 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { finish_overview_refresh(&mut app, &session, &mut rx).await; assert_eq!(app.agents_overview.threads, expected); assert!(!app.agents_overview.discovery.has_more()); + let old = app.agents_overview.threads[&ids[23]].as_ref().unwrap(); + assert_eq!(old.preview, "Task 23"); + assert_eq!( + app.agents_overview_details(old, &HashMap::new()) + .latest_prompt + .as_deref(), + Some("A later prompt") + ); + // Another CLI inserts a matching session ahead of the exhausted browse cursor. + show_reopened_pin.store(true, Ordering::SeqCst); + view.handle_key_event(KeyCode::Char('/').into()); + view.handle_paste("Pinned after reopen".into()); + finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?; + assert!( + app.agents_overview + .search_matches + .contains_key(&reopened_pin_id) + ); + view.handle_key_event(KeyCode::Esc.into()); app.agents_overview.threads.clear(); app.agents_overview.pinned_thread_ids = Some(Vec::new()); app.agents_overview.refill_count = 0; - show_reopened_pin.store(true, Ordering::SeqCst); app.refresh_agents_overview_threads(&session); finish_overview_refresh(&mut app, &session, &mut rx).await; assert_eq!( @@ -233,3 +300,26 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> { ); Ok(()) } + +async fn finish_history_search( + app: &mut App, + tui: &mut crate::tui::Tui, + session: &mut AppServerSession, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Result<()> { + while app + .agents_overview + .view_state + .lock() + .unwrap() + .history + .as_ref() + .is_some_and(|s| s.loading) + { + let event = tokio::time::timeout(Duration::from_secs(10), rx.recv()) + .await? + .expect("search event"); + app.handle_event(tui, session, event).await?; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/agents_overview_grouping.rs b/codex-rs/tui/src/app/agents_overview_grouping.rs index d3a5d9d7f7..5d093f402d 100644 --- a/codex-rs/tui/src/app/agents_overview_grouping.rs +++ b/codex-rs/tui/src/app/agents_overview_grouping.rs @@ -3,6 +3,7 @@ use super::super::App; use super::super::agents_overview::AgentsOverviewState; +use super::super::agents_overview_discovery::matches_query; use super::AgentsOverviewView; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; @@ -53,7 +54,7 @@ impl AgentsOverviewView { .is_some_and(|ranks| ranks.contains_key(&self.rows[index].thread_id)) } - pub(super) fn visible_indices(&self) -> Vec { + pub(in crate::app) fn visible_indices(&self) -> Vec { let state = self.state(); let search = state.search.to_lowercase(); let (_, status_group) = super::command_center::TASK_FILTERS[state.status_filter]; @@ -62,16 +63,10 @@ impl AgentsOverviewView { .iter() .enumerate() .filter_map(|(index, row)| { - let searchable = format!( - "{} {} {}", - row.thread.name.as_deref().unwrap_or_default(), - row.thread.preview, - row.thread.cwd.display(), - ) - .to_lowercase(); - ((search.is_empty() || searchable.contains(&search)) - && (state.rename_target == Some(row.thread_id) - || status_group.is_none_or(|group| group == row.group))) + (state.rename_target == Some(row.thread_id) + || (state.includes_row(row) + && (search.is_empty() || matches_query(&row.thread, &search)) + && status_group.is_none_or(|group| group == row.group))) .then_some(index) }) .partition(|index| self.is_pinned(*index)); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 429a9abfbf..114e685a8e 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -384,9 +384,9 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re cleared_unread: Vec::new(), last_messages: HashMap::new(), threads: HashMap::from([ - (retained, Some((retained_thread.clone(), false))), - (archived, Some((archived_thread, false))), - (deleted, Some((deleted_thread, false))), + (retained, Some((retained_thread.clone(), false, None))), + (archived, Some((archived_thread, false, None))), + (deleted, Some((deleted_thread, false, None))), ]), recent_seed_complete: true, discovery: None, @@ -1438,6 +1438,10 @@ async fn shared_overview_shows_only_root_sessions() { ); action_view.handle_key_event(KeyCode::Char('/').into()); assert!(action_view.handle_paste("Repair authentication".into())); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::SearchAgentsOverview(..)) + )); action_view.handle_key_event(KeyCode::Enter.into()); assert!( matches!(event_rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root) @@ -1580,6 +1584,10 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); assert!(view.handle_paste("Second task".to_string())); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::SearchAgentsOverview(..)) + )); view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); assert!(matches!( event_rx.try_recv(), diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 1f6927bf68..e4456c4bf2 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -57,6 +57,7 @@ fn detail_thread_ids<'a>(threads: impl Iterator) -> Vec { if change.read_state.first_unread.is_some() { @@ -127,6 +129,7 @@ impl App { self.agents_overview.removed_threads.remove(&thread_id); let mut thread = started.thread.clone(); thread.turns.clear(); + self.agents_overview.search_matches.remove(&thread_id); self.agents_overview.threads.insert(thread_id, Some(thread)); } ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { @@ -333,7 +336,7 @@ impl App { for (thread, is_unread) in pinned_threads { if let Ok(thread_id) = ThreadId::from_string(&thread.id) { ids.push(thread_id); - threads.insert(thread_id, Some((thread, is_unread))); + threads.insert(thread_id, Some((thread, is_unread, None))); } } Some(Some(ids)) @@ -377,7 +380,7 @@ impl App { thread_ids.extend(detail_thread_ids(recent.iter().map(|(thread, _)| thread))); for (thread, is_unread) in recent { if let Ok(thread_id) = ThreadId::from_string(&thread.id) { - threads.insert(thread_id, Some((thread, is_unread))); + threads.insert(thread_id, Some((thread, is_unread, None))); } } } @@ -417,7 +420,8 @@ impl App { }) .await { - Ok(mut response) => { + Ok(response) => { + let mut latest_prompt = None; let mut last_message = None; if let Ok(turns) = request_handle .request_typed::( @@ -441,9 +445,13 @@ impl App { content, .. }) = turn.item_payloads().next() { - response.thread.preview = - ChatWidget::user_message_display_from_inputs(content) - .message; + latest_prompt = + Some(super::agents_overview_details::preview_markdown( + &ChatWidget::user_message_display_from_inputs( + content, + ) + .message, + )); } last_message = turn.item_payloads().rev().find_map(|item| match item { @@ -456,7 +464,11 @@ impl App { let unread = response .read_state .is_some_and(|state| state.first_unread.is_some()); - Some((thread_id, (response.thread, unread), last_message)) + Some(( + thread_id, + (response.thread, unread, latest_prompt), + last_message, + )) } Err(error) => { tracing::warn!(%thread_id, %error, "failed to read agent thread"); diff --git a/codex-rs/tui/src/app/agents_overview_usage.rs b/codex-rs/tui/src/app/agents_overview_usage.rs index fe7f7f2313..47a4868d81 100644 --- a/codex-rs/tui/src/app/agents_overview_usage.rs +++ b/codex-rs/tui/src/app/agents_overview_usage.rs @@ -105,7 +105,9 @@ impl App { return; } self.agents_overview.pending_usage = None; - if self.agents_overview.threads.contains_key(&thread_id) { + if self.agents_overview.threads.contains_key(&thread_id) + || self.agents_overview.search_matches.contains_key(&thread_id) + { let usage = self.agents_overview.usage.entry(thread_id).or_default(); usage.fetched_at = Some(Instant::now()); match result { diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs index 5291b8999f..35fb2d47ef 100644 --- a/codex-rs/tui/src/app/agents_overview_usage_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -100,7 +100,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R request_id, Ok(AgentsOverviewThreadRefresh { cleared_unread: Vec::new(), - threads: HashMap::from([(selected, Some((threads[0].clone(), false)))]), + threads: HashMap::from([(selected, Some((threads[0].clone(), false, None)))]), last_messages: HashMap::new(), recent_seed_complete: true, discovery: None, diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 4f562cd194..ba65c2c4af 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -13,6 +13,7 @@ use grouping::model_name; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::agents_overview_details::AgentsOverviewDetails; +use super::agents_overview_discovery::HistorySearch; use super::agents_overview_discovery::supports_shared_pinning; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; @@ -107,6 +108,7 @@ fn is_subagent(thread: &Thread) -> bool { #[derive(Clone)] pub(super) struct AgentsOverviewRow { + pub(super) search_id: Option, pub(super) details: AgentsOverviewDetails, pub(super) thread: Thread, pub(super) thread_id: ThreadId, @@ -173,7 +175,9 @@ pub(super) struct AgentsOverviewViewState { /// Browse only while the server holds its lifecycle lock. pub(super) removal: Option, pub(super) server_version_notice: Option, - search: String, + pub(super) search: String, + pub(super) history: Option, + browse_selection: Option, searching: bool, pub(super) grouping: AgentsOverviewGrouping, pub(super) rename_target: Option, @@ -192,6 +196,29 @@ impl AgentsOverviewViewState { self.input = input; } + pub(super) fn restart_history_search(&mut self) { + self.history = + (!self.search.is_empty() && self.connection_notice.is_none()).then(|| HistorySearch { + id: uuid::Uuid::new_v4(), + target: self.history.as_ref().map_or(10, |search| search.target), + ..Default::default() + }); + } + + fn includes_row(&self, row: &AgentsOverviewRow) -> bool { + row.search_id.is_none() + || row.search_id == self.history.as_ref().map(|search| search.id) + || self.rename_target == Some(row.thread_id) + } + + fn more_available(&self) -> bool { + self.history + .as_ref() + .map_or(self.has_more && !self.searching, |search| { + search.cursor.has_more() + }) + } + pub(super) fn editing_metadata(&self) -> bool { self.searching || self.rename_target.is_some() } @@ -362,7 +389,36 @@ impl AgentsOverviewView { self.state().show_more_selected = self.selected == usize::MAX; } + pub(super) fn load_search_matches(&self, additional: usize) { + if self.state().history.is_none() { + return; + } + let visible = self.visible_indices().len(); + let mut state = self.state(); + if state.more_available() + && let Some(search) = &mut state.history + && !search.loading + { + if additional > 0 { + search.target = visible + additional; + search.failed = false; + } + if search.failed || visible >= search.target { + return; + } + search.loading = true; + self.app_event_tx.send(AppEvent::SearchAgentsOverview( + search.id, + search.target - visible, + )); + } + } + fn activate(&mut self) { + if self.selected == usize::MAX && self.state().searching { + self.load_search_matches(/*additional*/ 10); + return; + } if self.selected == usize::MAX && self.state().has_more { if !self.state().loading { self.state().loading = true; @@ -392,6 +448,7 @@ impl AgentsOverviewView { }); if self.state().searching { let mut state = self.state(); + state.history = None; state.search.clear(); state.searching = false; } @@ -403,9 +460,12 @@ impl AgentsOverviewView { let searching = state.searching; if searching { edit(&mut state.search); + state.restart_history_search(); } drop(state); if searching { + self.load_search_matches(/*additional*/ 10); + self.state().show_more_selected = false; self.selected = self .selectable_indices() .first() @@ -493,7 +553,12 @@ impl AgentsOverviewView { }) .collect::>() }; - let preview = super::agents_overview_details::preview_markdown(&row.thread.preview); + let preview = super::agents_overview_details::preview_markdown( + row.details + .latest_prompt + .as_deref() + .unwrap_or(&row.thread.preview), + ); let prompt_start = crate::wrapping::word_wrap_lines(lines.clone(), width).len(); lines.extend([Line::default(), Line::from("Prompt".dim())]); let prompt = crate::markdown_render::render_markdown_lines_with_width_and_cwd( @@ -588,11 +653,19 @@ impl BottomPaneView for AgentsOverviewView { fn on_ctrl_c(&mut self) -> CancellationEvent { let mut state = self.state(); if state.editing_metadata() { + let restore = state.searching.then_some(state.browse_selection); + state.history = None; state.searching = false; state.rename_target = None; state.search.clear(); state.input.set_text_clearing_elements(""); drop(state); + if let Some(selected) = restore { + self.selected = selected + .and_then(|id| self.rows.iter().position(|row| row.thread_id == id)) + .unwrap_or(usize::MAX); + self.state().show_more_selected = self.selected == usize::MAX; + } self.reconcile_command_center_selection(); return CancellationEvent::Handled; } @@ -650,9 +723,12 @@ impl BottomPaneView for AgentsOverviewView { if self.agents_keymap.search.is_pressed(key) { let mut state = self.state(); if state.rename_target.is_none() { - state.searching = !state.searching; - if !state.searching { - state.search.clear(); + if state.searching { + drop(state); + self.on_ctrl_c(); + } else { + state.browse_selection = self.rows.get(self.selected).map(|row| row.thread_id); + state.searching = true; } } return; @@ -740,6 +816,7 @@ impl BottomPaneView for AgentsOverviewView { row.thread.name.as_deref().unwrap_or_default(), &self.editor_keymap, ); + state.history = None; state.search.clear(); state.searching = false; state.rename_target = Some(row.thread_id); diff --git a/codex-rs/tui/src/app/backend_selection.rs b/codex-rs/tui/src/app/backend_selection.rs index 68ac70e0f3..e33c2a14ef 100644 --- a/codex-rs/tui/src/app/backend_selection.rs +++ b/codex-rs/tui/src/app/backend_selection.rs @@ -386,6 +386,7 @@ impl App { let destination = backends.session_at_mut(destination_slot); self.store_active_thread_receiver().await; self.clear_active_thread().await; + self.agents_overview.search_matches.clear(); self.agents_overview.pending_usage = None; self.agents_overview.usage_disabled = false; self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone()); @@ -393,6 +394,7 @@ impl App { self.reconnect = Default::default(); if let Ok(mut state) = self.agents_overview.view_state.lock() { state.connection_notice = None; + state.restart_history_search(); } self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1); self.rate_limit_refresh_state.invalidate_recovery(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 3881aacea9..4acd12990e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -415,6 +415,7 @@ impl App { if !thread.ephemeral && !self.agents_overview.removed_threads.contains(&thread_id) && !self.agents_overview.threads.get(&thread_id).is_some_and(Option::is_some) { + self.agents_overview.search_matches.remove(&thread_id); self.agents_overview.threads.insert(thread_id, Some(thread)); self.agents_overview.refresh_thread_ids.insert(thread_id); } @@ -3192,6 +3193,10 @@ impl App { } } AppEvent::OpenAgentsOverview => self.open_agents_overview(app_server), + AppEvent::SearchAgentsOverview(id, limit) => self.search_agents_overview(app_server.request_handle(), id, limit), + AppEvent::AgentsOverviewSearchPage { id, rows, cursor, ok } => { + self.apply_agents_overview_search_page(id, rows, cursor, ok); + } AppEvent::ShowMoreAgentsOverview => self.show_more_agents_overview(app_server), AppEvent::RefreshViewedThread { backend_id, thread_id, revision } => { if app_server.backend_id() == backend_id { diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index d6a7078caa..3b0a649662 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -378,6 +378,7 @@ impl App { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.connection_notice = None; + state.restart_history_search(); if state.creating_worktree { state.creating_worktree = false; self.pending_managed_worktree_creation = false; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_search_active.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_search_active.snap index e4ac0f1f2c..ed8eb146fe 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_search_active.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_search_active.snap @@ -10,7 +10,7 @@ expression: "screen(&view, area.width, area.height)" /tmp/project 1 › ○ Task to find Ready 2m ago - + Searching history… diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 81efcc263f..ca127d7080 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -438,7 +438,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv last_messages: HashMap::new(), threads: stale_threads .into_iter() - .map(|(id, thread)| (id, thread.map(|thread| (thread, false)))) + .map(|(id, thread)| (id, thread.map(|thread| (thread, false, None)))) .collect(), recent_seed_complete: true, discovery: None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 07f509dfb1..326457bb8e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -281,7 +281,7 @@ pub(crate) enum RecapTrigger { #[derive(Debug)] pub(crate) struct AgentsOverviewThreadRefresh { pub(crate) cleared_unread: Vec, - pub(crate) threads: std::collections::HashMap>, + pub(crate) threads: std::collections::HashMap)>>, pub(crate) last_messages: std::collections::HashMap, pub(crate) recent_seed_complete: bool, pub(crate) discovery: Option, @@ -367,6 +367,13 @@ pub(crate) enum AppEvent { /// Open the live command center for recent and locally retained root sessions. OpenAgentsOverview, ShowMoreAgentsOverview, + SearchAgentsOverview(Uuid, usize), + AgentsOverviewSearchPage { + id: Uuid, + rows: Vec<(Thread, bool)>, + cursor: crate::app::agents_overview_discovery::AgentsOverviewDiscovery, + ok: bool, + }, RefreshViewedThread { backend_id: crate::backend_id::BackendId, thread_id: ThreadId,