diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff2e7f8142..8d20fe5534 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -206,6 +206,7 @@ mod agent_navigation; mod agent_picker; mod agent_status_feed; mod agents_overview; +mod agents_overview_details; mod agents_overview_threads; mod agents_overview_view; pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID; diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 9fabe3e5a9..2bef831f0e 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -28,6 +28,8 @@ 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>, + pub(super) last_messages: HashMap, + pub(super) activity: HashMap, pub(super) initialized: bool, pub(super) request_id: Option, pub(super) refresh_pending: bool, @@ -133,10 +135,15 @@ impl App { match result { Ok(refresh) => { self.agents_overview.initialized = refresh.recent_seed_complete; + self.agents_overview + .last_messages + .extend(refresh.last_messages); for (thread_id, thread) in refresh.threads { if let Some(mut thread) = thread { if thread.ephemeral { self.agents_overview.threads.remove(&thread_id); + self.agents_overview.last_messages.remove(&thread_id); + self.agents_overview.activity.remove(&thread_id); continue; } thread.turns.clear(); @@ -162,6 +169,13 @@ impl App { std::mem::take(&mut self.agents_overview.refresh_notifications).into_values() { for notification in notifications { + if let ServerNotification::ThreadReverted(reverted) = ¬ification + && let Ok(thread_id) = ThreadId::from_string(&reverted.thread_id) + { + // Discard stale read results without clearing activity received after the revert. + self.agents_overview.last_messages.remove(&thread_id); + continue; + } self.track_agents_overview_notification(¬ification); } } @@ -253,6 +267,7 @@ impl App { continue; }; rows.push(AgentsOverviewRow { + details: self.agents_overview_details(root, &children), thread: root.clone(), thread_id, group, diff --git a/codex-rs/tui/src/app/agents_overview_details.rs b/codex-rs/tui/src/app/agents_overview_details.rs new file mode 100644 index 0000000000..00cb7e76fc --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_details.rs @@ -0,0 +1,253 @@ +//! Bounded overview previews from existing reads and already-delivered events. +//! Observing activity never attaches to a thread or fetches additional history. + +use super::App; +use super::ThreadBufferedEvent; +use super::ThreadEventAttachment; +use super::agents_overview_view::AgentsOverviewGroup; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadActiveFlag; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStatus; +use codex_protocol::ThreadId; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::collections::HashMap; + +const PREVIEW_CHARS: usize = 512; + +pub(super) fn preview_text(text: &str) -> String { + text.chars() + .map(|ch| if ch.is_whitespace() { ' ' } else { ch }) + .filter(|ch| !ch.is_control()) + .take(PREVIEW_CHARS) + .collect() +} + +#[derive(Default)] +pub(super) struct AgentsOverviewActivity { + reasoning: Option, + last_message: Option, +} + +struct ReasoningPreview { + turn_id: String, + item_id: String, + summary_index: i64, + buffer: String, + header: Option, +} + +impl App { + pub(super) fn track_agents_overview_activity( + &mut self, + thread_id: ThreadId, + notification: &ServerNotification, + ) { + if !self.agents_overview.threads.contains_key(&thread_id) { + return; + } + let activity = self.agents_overview.activity.entry(thread_id).or_default(); + let changed = match notification { + ServerNotification::ReasoningSummaryTextDelta(delta) => { + let had_header = activity + .reasoning + .as_ref() + .is_some_and(|reasoning| reasoning.header.is_some()); + if activity.reasoning.as_ref().is_none_or(|reasoning| { + reasoning.turn_id != delta.turn_id + || reasoning.item_id != delta.item_id + || reasoning.summary_index != delta.summary_index + }) { + activity.reasoning = Some(ReasoningPreview { + turn_id: delta.turn_id.clone(), + item_id: delta.item_id.clone(), + summary_index: delta.summary_index, + buffer: String::new(), + header: None, + }); + } + let Some(reasoning) = activity.reasoning.as_mut() else { + return; + }; + if reasoning.header.is_some() { + return; + } + let remaining = PREVIEW_CHARS.saturating_sub(reasoning.buffer.chars().count()); + reasoning.buffer.extend(delta.delta.chars().take(remaining)); + reasoning.header = crate::chatwidget::extract_first_bold(&reasoning.buffer) + .map(|header| preview_text(&header)); + had_header || reasoning.header.is_some() + } + ServerNotification::ItemCompleted(ItemCompletedNotification { + item: ThreadItem::AgentMessage { text, .. }, + .. + }) => { + activity.last_message = Some(preview_text(text)); + activity.reasoning = None; + true + } + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_) + | ServerNotification::ReasoningSummaryPartAdded(_) + | ServerNotification::ItemStarted(_) => activity.reasoning.take().is_some(), + ServerNotification::ThreadStatusChanged(status) + if !matches!(status.status, ThreadStatus::Active { .. }) => + { + activity.reasoning.take().is_some() + } + _ => false, + }; + if changed { + self.repaint_agents_overview(); + } + } + + pub(super) fn agents_overview_details( + &self, + root: &Thread, + children: &HashMap>, + ) -> Vec> { + let mut lines = Vec::new(); + // Urgency comes first; on ties, prefer available current detail over history. + let priority = |thread: &Thread| { + let group = AgentsOverviewGroup::for_status(&thread.status); + let has_detail = ThreadId::from_string(&thread.id).is_ok_and(|id| match group { + AgentsOverviewGroup::NeedsYou => self.agents_overview_request_preview(id).is_some(), + AgentsOverviewGroup::Working => { + self.thread_event_channels + .get(&id) + .is_none_or(|channel| channel.attachment() == ThreadEventAttachment::Live) + && self + .agents_overview + .activity + .get(&id) + .and_then(|activity| activity.reasoning.as_ref()) + .is_some_and(|reasoning| reasoning.header.is_some()) + } + AgentsOverviewGroup::Ready | AgentsOverviewGroup::Finished => false, + }); + (group, !has_detail) + }; + let mut source = root; + let mut pending = vec![root]; + while let Some(thread) = pending.pop() { + if priority(thread) < priority(source) { + source = thread; + } + pending.extend(children.get(&thread.id).into_iter().flatten().copied()); + pending.sort_by(|left, right| right.id.cmp(&left.id)); + } + let Ok(thread_id) = ThreadId::from_string(&source.id) else { + return lines; + }; + let channel = self.thread_event_channels.get(&thread_id); + let is_child = source.id != root.id; + if is_child { + let name = source + .agent_nickname + .as_deref() + .or(source.name.as_deref()) + .unwrap_or(&source.id); + lines.push(Line::default()); + lines.push(vec!["Agent: ".dim(), preview_text(name).into()].into()); + } + if AgentsOverviewGroup::for_status(&source.status) == AgentsOverviewGroup::NeedsYou { + if !is_child { + lines.extend([Line::default(), "Needs attention".red().into()]); + } + if let Some(request) = self.agents_overview_request_preview(thread_id) { + lines.push(request.into()); + } + lines.push("Open task to review.".dim().into()); + match &source.status { + ThreadStatus::Active { active_flags } => { + if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval) { + lines.push("Waiting for approval.".into()); + } + if active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) { + lines.push("Waiting for your response.".into()); + } + } + ThreadStatus::SystemError => lines.push("Task encountered an error.".into()), + ThreadStatus::NotLoaded | ThreadStatus::Idle => {} + } + } + let activity = self.agents_overview.activity.get(&thread_id).filter(|_| { + channel.is_none_or(|channel| channel.attachment() == ThreadEventAttachment::Live) + }); + if matches!(source.status, ThreadStatus::Active { .. }) + && let Some(header) = activity + .and_then(|activity| activity.reasoning.as_ref()) + .and_then(|reasoning| reasoning.header.as_ref()) + { + if !is_child { + lines.push(Line::default()); + } + lines.extend(["Latest activity".dim().into(), header.clone().into()]); + } + if let Some(message) = activity + .and_then(|activity| activity.last_message.as_ref()) + .or_else(|| self.agents_overview.last_messages.get(&thread_id)) + .filter(|message| !message.trim().is_empty()) + { + lines.extend([ + Line::default(), + "Last message".dim().into(), + message.clone().into(), + ]); + } + lines + } + + fn agents_overview_request_preview(&self, thread_id: ThreadId) -> Option { + self.agents_overview + .dispatched_requests + .get(&thread_id) + .and_then(|requests| requests.iter().find_map(request_preview)) + .or_else(|| { + let channel = self + .thread_event_channels + .get(&thread_id) + .filter(|channel| channel.attachment() == ThreadEventAttachment::Live)?; + let store = channel.store.try_lock().ok()?; + store.buffer.iter().find_map(|event| { + if let ThreadBufferedEvent::Request(request) = event + && store + .pending_interactive_replay + .should_replay_snapshot_request(request) + { + request_preview(request) + } else { + None + } + }) + }) + } +} + +fn request_preview(request: &ServerRequest) -> Option { + let text = match request { + ServerRequest::CommandExecutionRequestApproval { params, .. } => { + params.reason.as_deref().or(params.command.as_deref()) + } + ServerRequest::FileChangeRequestApproval { params, .. } => params.reason.as_deref(), + ServerRequest::PermissionsRequestApproval { params, .. } => params.reason.as_deref(), + ServerRequest::McpServerElicitationRequest { params, .. } => match ¶ms.request { + McpServerElicitationRequest::Form { message, .. } + | McpServerElicitationRequest::OpenAiForm { message, .. } + | McpServerElicitationRequest::OpenAiElicitationForm { message, .. } + | McpServerElicitationRequest::Url { message, .. } => Some(message.as_str()), + }, + ServerRequest::ToolRequestUserInput { params, .. } => params + .questions + .first() + .map(|question| question.question.as_str()), + _ => None, + }?; + Some(preview_text(text)) +} diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index d9a9d4e364..dc79d7e297 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -10,6 +10,7 @@ use crate::test_support::test_path_buf; use crate::test_support::test_path_display; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::CurrentTimeReadParams; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadActiveFlag; @@ -23,6 +24,8 @@ use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::ThreadUnsubscribeResponse; use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_config::types::KeybindingSpec; use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; @@ -315,6 +318,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re &app_server, request_id, Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), threads: HashMap::from([ (retained, Some(retained_thread.clone())), (archived, Some(archived_thread)), @@ -337,6 +341,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re &app_server, request_id, Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), threads: HashMap::from([(retained, None)]), recent_seed_complete: true, }), @@ -350,6 +355,50 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re Err("temporarily unavailable".to_string()), ); assert_eq!(app.agents_overview.threads, expected); + + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let stale_messages = HashMap::from([(retained, "Removed answer".into())]); + for event in [ + AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadReverted( + codex_app_server_protocol::ThreadRevertedNotification { + thread_id: retained.to_string(), + }, + ))), + AppServerEvent::Lagged { skipped: 1 }, + ] { + let old_request = Uuid::new_v4(); + app.agents_overview.request_id = Some(old_request); + app.agents_overview.activity.entry(retained).or_default(); + app.agents_overview.last_messages = stale_messages.clone(); + app.handle_app_server_event(&app_server, event).await; + assert!(!app.agents_overview.activity.contains_key(&retained)); + assert!(app.agents_overview.last_messages.is_empty()); + // Fresh activity must survive an older read arriving after invalidation. + app.track_agents_overview_notification(&reasoning_delta( + retained, + "new-reasoning", + "**Checking the revised task**", + )); + let current_request = app.agents_overview.request_id.unwrap(); + for (request_id, last_messages) in [ + (old_request, stale_messages.clone()), + (current_request, HashMap::new()), + ] { + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: HashMap::new(), + last_messages, + recent_seed_complete: true, + }), + ); + assert!(app.agents_overview.last_messages.is_empty()); + } + assert!(app.agents_overview.activity.contains_key(&retained)); + assert!(app.agents_overview.request_id.is_none()); + } app_server.shutdown().await?; Ok(()) } @@ -383,6 +432,22 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res .expect("materialize historical session"), )?); } + let message_path = app_test_support::rollout_path( + &app.config.codex_home, + "2025-01-21T12-00-00", + &ids[20].to_string(), + ); + let mut history = std::fs::read_to_string(&message_path)?; + history.push_str( + &serde_json::json!({ + "timestamp": "2025-01-21T12:00:01Z", + "type": "event_msg", + "payload": { "type": "agent_message", "message": "Found the regression in the parser." } + }) + .to_string(), + ); + history.push('\n'); + std::fs::write(message_path, history)?; let config = app.config.clone(); let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; for thread_id in [ids[0], ids[21]] { @@ -409,6 +474,12 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res expected.insert(ids[0]); let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect(); assert_eq!(retained, expected); + assert_eq!( + app.agents_overview.last_messages, + HashMap::from([(ids[20], "Found the regression in the parser.".to_string())]) + ); + let thread = app.agents_overview.threads[&ids[20]].as_ref().unwrap(); + assert_eq!(thread.status, ThreadStatus::NotLoaded); let created = app_server.start_thread(&config).await?.session.thread_id; // Closing the view must not cancel a metadata refresh or forget unloaded entries. @@ -595,6 +666,219 @@ async fn hidden_system_thread_does_not_refresh_shared_overview() { app_server.shutdown().await.expect("shutdown app server"); } +#[tokio::test] +async fn agents_overview_details_show_available_attention_without_expanding_rows() -> Result<()> { + let mut app = make_test_app().await; + let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let [root, child, unloaded] = std::array::from_fn(|_| ThreadId::new()); + let waiting = ThreadStatus::Active { + active_flags: vec![ + ThreadActiveFlag::WaitingOnApproval, + ThreadActiveFlag::WaitingOnUserInput, + ], + }; + // The child question takes priority over the generic waiting parent. + let mut threads = [ + (root, None, "Repair authentication", waiting.clone()), + (child, Some(root), "Check dependencies", waiting), + ( + unloaded, + None, + "Investigate parser", + ThreadStatus::NotLoaded, + ), + ] + .map(|(id, parent, title, status)| overview_thread(id, parent, title, status)) + .to_vec(); + threads[0].name = None; + threads[2].name = None; + threads[2].preview = "Investigate parser\nInclude edge cases\n".repeat(8); + app.agents_overview.threads = threads + .iter() + .cloned() + .map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread))) + .collect(); + app.agents_overview.last_messages.insert( + unloaded, + super::super::agents_overview_details::preview_text( + &"Found the regression\nin the parser. ".repeat(20), + ), + ); + app.agents_overview.dispatched_requests.insert( + child, + vec![ServerRequest::ToolRequestUserInput { + request_id: RequestId::Integer(42), + params: ToolRequestUserInputParams { + thread_id: child.to_string(), + turn_id: "turn".into(), + item_id: "question".into(), + questions: vec![ToolRequestUserInputQuestion { + id: "version".into(), + header: "Version".into(), + question: "Which dependency version should I use?".into(), + is_other: false, + is_secret: false, + options: None, + }], + is_blocking: true, + auto_resolution_ms: None, + }, + }], + ); + let view = app.agents_overview_view(threads.clone(), Some(root)); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let project = test_path_display("/tmp/project"); + let normalized_group = format!( + "/tmp/project 2{}", + " ".repeat(project.len().saturating_sub("/tmp/project".len())) + ); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_attention", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project")); + }); + app.handle_app_server_event( + &app_server, + AppServerEvent::ServerNotification(Box::new(ServerNotification::ServerRequestResolved( + codex_app_server_protocol::ServerRequestResolvedNotification { + thread_id: child.to_string(), + request_id: RequestId::Integer(42), + }, + ))), + ) + .await; + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); + assert!(!rendered.contains("Which dependency version")); + assert!(rendered.contains("Waiting for approval.")); + assert!(app.thread_event_channels.is_empty()); + assert!(app.agents_overview.request_id.is_none()); + + let view = app.agents_overview_view(threads, Some(unloaded)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_last_message", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project")); + }); + app.track_agents_overview_notification(&ServerNotification::ThreadReverted( + codex_app_server_protocol::ThreadRevertedNotification { + thread_id: unloaded.to_string(), + }, + )); + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Last message")); + app_server.shutdown().await?; + Ok(()) +} + +fn reasoning_delta(thread_id: ThreadId, item_id: &str, delta: &str) -> ServerNotification { + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: "turn".into(), + item_id: item_id.into(), + summary_index: 0, + delta: delta.into(), + }) +} + +#[tokio::test] +async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachment() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let mut thread = overview_thread( + thread_id, + /*parent_thread_id*/ None, + "Check startup performance", + ThreadStatus::Active { + active_flags: Vec::new(), + }, + ); + thread.preview.clear(); + app.agents_overview + .threads + .insert(thread_id, Some(thread.clone())); + app.thread_event_channels + .insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8)); + let mut view = app.agents_overview_view(vec![thread.clone()], Some(thread_id)); + view.handle_paste("Keep this draft".into()); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + for delta in ["**Checking", " cold-start regressions**\nFurther reasoning"] { + app.track_agents_overview_notification(&reasoning_delta(thread_id, "reasoning", delta)); + } + let project = test_path_display("/tmp/project"); + let normalized_group = format!( + "/tmp/project 1{}", + " ".repeat(project.len().saturating_sub("/tmp/project".len())) + ); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_live_activity", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 1"), &normalized_group).replace(&project, "/tmp/project")); + }); + assert!(app.agents_overview.request_id.is_none()); + + // A working child can stream through a server attachment without a local channel. + let parent = overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + "Parent", + thread.status.clone(), + ); + let parent_id = ThreadId::from_string(&parent.id).unwrap(); + app.agents_overview + .threads + .insert(parent_id, Some(parent.clone())); + app.track_agents_overview_notification(&ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + thread_id: parent.id.clone(), + turn_id: "previous-turn".into(), + completed_at_ms: 0, + item: ThreadItem::AgentMessage { + id: "answer".into(), + text: "Previous answer".into(), + phase: None, + memory_citation: None, + delivery: None, + questions: None, + }, + }, + )); + let channel = app.thread_event_channels.remove(&thread_id).unwrap(); + let details = app.agents_overview_details( + &parent, + &HashMap::from([(parent.id.clone(), vec![&thread])]), + ); + assert!( + details + .iter() + .any(|line| line.to_string().contains("Checking cold-start regressions")) + ); + app.thread_event_channels.insert(thread_id, channel); + + // A new, oversized header clears the previous one and cannot grow the parsing buffer indefinitely. + for delta in [format!("**{}", "界".repeat(10_000)), "**".into()] { + app.track_agents_overview_notification(&reasoning_delta(thread_id, "oversized", &delta)); + } + assert_eq!( + app.agents_overview_details(&thread, &HashMap::new()), + Vec::::new() + ); + app.track_agents_overview_notification(&reasoning_delta( + thread_id, + "next", + "**Running tests**", + )); + app.thread_event_channels + .get_mut(&thread_id) + .unwrap() + .mark_replay_only(); + assert_eq!( + app.agents_overview_details(&thread, &HashMap::new()), + Vec::::new() + ); + app.track_agents_overview_notification(&ServerNotification::ThreadClosed( + ThreadClosedNotification { + thread_id: thread_id.to_string(), + }, + )); + assert!(!app.agents_overview.activity.contains_key(&thread_id)); +} + #[tokio::test] async fn shared_overview_shows_only_root_sessions() { assert_eq!( diff --git a/codex-rs/tui/src/app/agents_overview_threads.rs b/codex-rs/tui/src/app/agents_overview_threads.rs index 8a3e86aa6d..85e37be74b 100644 --- a/codex-rs/tui/src/app/agents_overview_threads.rs +++ b/codex-rs/tui/src/app/agents_overview_threads.rs @@ -4,6 +4,7 @@ use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use super::agents_overview_details::preview_text; use super::app_server_event_targets::ServerNotificationThreadTarget; use super::app_server_event_targets::server_notification_thread_target; use crate::AppServerTarget; @@ -40,6 +41,7 @@ impl App { else { return; }; + self.track_agents_overview_activity(thread_id, notification); let thread = self .agents_overview .threads @@ -55,14 +57,22 @@ impl App { self.agents_overview.threads.insert(thread_id, Some(thread)); } ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => { + self.agents_overview.activity.remove(&thread_id); + self.agents_overview.last_messages.remove(&thread_id); self.agents_overview.threads.remove(&thread_id); self.agents_overview.refresh_thread_ids.remove(&thread_id); } ServerNotification::ThreadClosed(_) => { + self.agents_overview.activity.remove(&thread_id); if let Some(thread) = thread { thread.status = ThreadStatus::NotLoaded; } } + ServerNotification::ThreadReverted(_) => { + self.agents_overview.activity.remove(&thread_id); + self.agents_overview.last_messages.remove(&thread_id); + self.repaint_agents_overview(); + } ServerNotification::ThreadStatusChanged(status) => { if let Some(thread) = thread { thread.status = status.status.clone(); @@ -83,7 +93,9 @@ impl App { } _ => return, } - if self.agents_overview.threads.contains_key(&thread_id) { + if !matches!(notification, ServerNotification::ThreadReverted(_)) + && self.agents_overview.threads.contains_key(&thread_id) + { self.agents_overview.refresh_thread_ids.insert(thread_id); } if self.agents_overview.request_id.is_some() { @@ -151,6 +163,7 @@ impl App { let refresh_task = tokio::spawn(async move { let result = async { let mut threads = HashMap::new(); + let mut last_messages = HashMap::new(); let mut recent_seed_complete = true; if !initialized { let loaded = request_handle.request_typed::( @@ -281,6 +294,7 @@ impl App { .await { Ok(mut response) => { + let mut last_message = None; if let Ok(turns) = request_handle .request_typed::( ClientRequest::ThreadTurnsList { @@ -297,14 +311,24 @@ impl App { }, ) .await - && let Some(ThreadItem::UserMessage { content, .. }) = - turns.data.first().and_then(|turn| turn.items.first()) + && let Some(turn) = turns.data.first() { - response.thread.preview = - ChatWidget::user_message_display_from_inputs(content) - .message; + if let Some(ThreadItem::UserMessage { content, .. }) = + turn.items.first() + { + response.thread.preview = + ChatWidget::user_message_display_from_inputs(content) + .message; + } + last_message = + turn.items.iter().rev().find_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => { + Some(preview_text(text)) + } + _ => None, + }); } - Some((thread_id, response.thread)) + Some((thread_id, response.thread, last_message)) } Err(error) => { tracing::warn!(%thread_id, %error, "failed to read agent thread"); @@ -313,18 +337,26 @@ impl App { } }); if reads.len() >= 16 - && let Some(Ok(Some((thread_id, thread)))) = reads.join_next().await + && let Some(Ok(Some((thread_id, thread, last_message)))) = + reads.join_next().await { threads.insert(thread_id, Some(thread)); + if let Some(message) = last_message { + last_messages.insert(thread_id, message); + } } } while let Some(result) = reads.join_next().await { - if let Ok(Some((thread_id, thread))) = result { + if let Ok(Some((thread_id, thread, last_message))) = result { threads.insert(thread_id, Some(thread)); + if let Some(message) = last_message { + last_messages.insert(thread_id, message); + } } } Ok(AgentsOverviewThreadRefresh { threads, + last_messages, recent_seed_complete, }) } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 309d44e167..e955a51bc9 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -84,12 +84,18 @@ impl AgentsOverviewGroup { #[derive(Clone)] pub(super) struct AgentsOverviewRow { + pub(super) details: Vec>, pub(super) thread: Thread, pub(super) thread_id: ThreadId, pub(super) group: AgentsOverviewGroup, pub(super) is_current: bool, } +fn display_title(thread: &Thread) -> &str { + let title = thread.name.as_deref().unwrap_or(&thread.preview); + title.trim().lines().next().unwrap_or("Untitled task") +} + #[derive(Default)] pub(super) struct AgentsOverviewViewState { // Search and rename never borrow the new-task draft. @@ -380,12 +386,6 @@ impl AgentsOverviewView { } else { " ".into() }; - let title = row - .thread - .name - .as_deref() - .or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str())) - .unwrap_or("Untitled task"); let (status, dot) = Self::status(row); let current = if row.is_current { " current" } else { "" }; let mut spans = vec![ @@ -393,7 +393,7 @@ impl AgentsOverviewView { " ".into(), dot, " ".into(), - title.into(), + display_title(&row.thread).into(), current.dim(), ]; if project_grouping { @@ -409,10 +409,14 @@ impl AgentsOverviewView { return; }; let (status, dot) = Self::status(row); + let width = usize::from(area.width); let mut lines = vec![ Line::from("Task details".bold()), Line::default(), - Line::from(row.thread.name.as_deref().unwrap_or("Untitled task").bold()), + crate::line_truncation::truncate_line_with_ellipsis_if_overflow( + display_title(&row.thread).to_owned().bold().into(), + width, + ), Line::from(vec![dot, " ".into(), status.into()]), Line::default(), Line::from("Project".dim()), @@ -428,23 +432,36 @@ impl AgentsOverviewView { lines.push("Branch".dim().into()); lines.push(branch.clone().into()); } - let preview = crate::text_formatting::truncate_text( - &row.thread.preview, - usize::from(area.width) * usize::from(area.height), - ); - lines.extend([ - Line::default(), - Line::from("Latest activity".dim()), - Line::from(match preview.as_str() { - "" => "No activity yet.", + let preview = crate::text_formatting::truncate_text(&row.thread.preview, width * 2); + lines.extend([Line::default(), Line::from("Prompt".dim())]); + let mut prompt = crate::wrapping::word_wrap_lines( + match preview.as_str() { + "" => "No prompt available.", preview => preview, - }), - ]); - Paragraph::new(crate::wrapping::word_wrap_lines( - lines, - usize::from(area.width), - )) - .render(area, buf); + } + .lines() + .map(Line::from), + width, + ); + if prompt.len() > 2 { + prompt.truncate(2); + prompt[1] = "…".dim().into(); + } + lines.extend(prompt); + let details_start = crate::wrapping::word_wrap_lines(lines[..4].to_vec(), width).len(); + let mut lines = crate::wrapping::word_wrap_lines(lines, width); + if self.state().connection_notice.is_none() { + let mut details = crate::wrapping::word_wrap_lines(row.details.clone(), width); + let available = usize::from(area.height).saturating_sub(lines.len()); + if details.len() > available { + details.truncate(available); + if let Some(last) = details.last_mut() { + *last = "…".dim().into(); + } + } + lines.splice(details_start..details_start, details); + } + Paragraph::new(lines).render(area, buf); } } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 145c7e021a..9137eef904 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -68,15 +68,32 @@ impl App { ); self.refresh_mcp_startup_expected_servers_from_config(); self.chat_widget.finish_mcp_startup_after_lag(); + if let Some(task) = self.agents_overview.refresh_task.take() { + task.abort(); + } + self.agents_overview.request_id = None; + self.agents_overview.refresh_pending = false; + self.agents_overview.refresh_notifications.clear(); + self.agents_overview.activity.clear(); + self.agents_overview.last_messages.clear(); + self.repaint_agents_overview(); self.refresh_agents_overview_threads(app_server_client); } AppServerEvent::ServerNotification(notification) => { + let request_resolved = matches!( + notification.as_ref(), + ServerNotification::ServerRequestResolved(_) + ); self.handle_server_notification_event(app_server_client, *notification) .await; + if request_resolved { + self.repaint_agents_overview(); + } } AppServerEvent::ServerRequest(request) => { self.handle_server_request_event(app_server_client, *request) .await; + self.repaint_agents_overview(); } AppServerEvent::Disconnected { message } => { if self.begin_reconnect() { diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index fd8232cff5..77a1579f7a 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -186,6 +186,8 @@ impl App { self.agents_overview.request_id = None; self.agents_overview.refresh_pending = false; self.agents_overview.refresh_notifications.clear(); + self.agents_overview.activity.clear(); + self.agents_overview.last_messages.clear(); self.reconnect.presentation = if self .chat_widget .selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 7dcb1b657b..e783ca17c0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -446,6 +446,7 @@ impl App { (session, turns, false) } }; + self.agents_overview.activity.remove(&thread_id); let recap_progress = if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) { let store = channel.store.lock().await; @@ -701,6 +702,7 @@ impl App { pub(super) fn reset_thread_event_state(&mut self) { self.abort_all_thread_event_listeners(); self.thread_event_channels.clear(); + self.agents_overview.activity.clear(); self.agent_navigation.clear(); self.side_threads.clear(); self.active_thread_id = None; diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index dcbc7b9773..9193b4da70 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -515,6 +515,7 @@ impl App { }); self.abort_thread_event_listener(thread_id); self.thread_event_channels.remove(&thread_id); + self.agents_overview.activity.remove(&thread_id); self.side_threads.remove(&thread_id); self.agent_navigation.remove(thread_id); if self.active_thread_id == Some(thread_id) { 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 d388bbff71..26471fadcb 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -128,6 +128,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .map(|thread| Ok((ThreadId::from_string(&thread.id)?, Some(thread.clone())))) .collect::>>()?; app.agents_overview.threads = stale_threads.clone(); + app.agents_overview + .last_messages + .insert(selected, "Pre-disconnect answer".into()); app.agents_overview.initialized = overview_initialized; let view = app.agents_overview_view( stale.clone(), @@ -248,6 +251,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv let disconnected = session.next_event().await.unwrap(); app.handle_app_server_event(&session, disconnected).await; assert!(app.reconnect.offline); + assert!(app.agents_overview.last_messages.is_empty()); assert!(refresh.await.unwrap_err().is_cancelled()); assert_eq!( ( @@ -260,6 +264,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv &session, stale_request, Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), threads: HashMap::new(), recent_seed_complete: false, }), @@ -381,6 +386,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv &session, stale_request, Ok(AgentsOverviewThreadRefresh { + last_messages: HashMap::new(), threads: stale_threads, recent_seed_complete: true, }), diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap index fdf59fdae2..3c29537e8a 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_failed.snap @@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ Project │ /tmp/project │ - │ Latest activity + │ Prompt │ Selected task │ │ diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap index c049dcc032..73efe2e3c5 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_reconnecting.snap @@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ Project │ /tmp/project │ - │ Latest activity + │ Prompt │ Selected task │ │ diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap index 9b17c2f0c2..511e2dfa0c 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ Project │ /tmp/project │ - │ Latest activity + │ Prompt │ New task │ │ diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ff72819fa..07ecf99923 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -218,6 +218,7 @@ pub(crate) enum RecapTrigger { #[derive(Debug)] pub(crate) struct AgentsOverviewThreadRefresh { pub(crate) threads: std::collections::HashMap>, + pub(crate) last_messages: std::collections::HashMap, pub(crate) recent_seed_complete: bool, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9c81378d97..eba19306ee 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2024,7 +2024,7 @@ const SIDE_PLACEHOLDER: &str = "Ask a follow-up question"; // Extract the first bold (Markdown) element in the form **...** from `s`. // Returns the inner text if found; otherwise `None`. -fn extract_first_bold(s: &str) -> Option { +pub(crate) fn extract_first_bold(s: &str) -> Option { let bytes = s.as_bytes(); let mut i = 0usize; while i + 1 < bytes.len() { diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap index 745e7f3b22..184ef30929 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -9,17 +9,17 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj › ● Build the dashboard current Needs input │ ● Repair authentication Working │ Build the dashboard ✓ Review yesterday's changes Finished │ ● Needs input + │ + │ Agent: Inspect keyboard shortcuts + │ Open task to review. + │ Waiting for approval. │ │ Project │ /tmp/project │ - │ Latest activity + │ Prompt │ Build the dashboard │ - │ - │ - │ - │ New task › First line diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap new file mode 100644 index 0000000000..2a46cc98eb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_attention.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 1 need input 0 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + › ● Repair authentication Needs input │ + ✓ Investigate parser Finished │ Repair authentication + │ ● Needs input + │ + │ Agent: Check dependencies + │ Which dependency version should I use? + │ Open task to review. + │ Waiting for approval. + │ Waiting for your response. + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ Repair authentication + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap new file mode 100644 index 0000000000..8954549f5a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_last_message.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 1 need input 0 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 2 │ Task details + ● Repair authentication Needs input │ + › ✓ Investigate parser Finished │ Investigate parser + │ ✓ Finished + │ + │ Last message + │ Found the regression in the parser. + │ Found the regression in the parser. + │ … + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ Investigate parser + │ … + New task + +› Describe a new task + + enter create task ctrl+j newline esc tasks diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap new file mode 100644 index 0000000000..1266cc264f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_live_activity.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")" +--- + Agent command center + 0 need input 1 working 0 ready + ──────────────────────────────────────────────────────────────────────────────────────────── + /tmp/project 1 │ Task details + › ● Check startup performance Working │ + │ Check startup performance + │ ● Working + │ + │ Latest activity + │ Checking cold-start regressions + │ + │ Project + │ /tmp/project + │ + │ Prompt + │ No prompt available. + │ + │ + │ + New task + +› Keep this draft + + enter create task ctrl+j newline esc tasks