diff --git a/codex-rs/tui/src/app/agent_center/render.rs b/codex-rs/tui/src/app/agent_center/render.rs index 1d8adef4f8..ade3530cd9 100644 --- a/codex-rs/tui/src/app/agent_center/render.rs +++ b/codex-rs/tui/src/app/agent_center/render.rs @@ -217,6 +217,16 @@ impl Renderable for AgentsOverviewView { .creating_worktree .then(|| "Creating worktree…".to_owned()) }) + .or_else(|| { + state.removal.map(|action| match action { + AgentsOverviewAction::Archive => { + "Archiving task… You can still browse.".to_owned() + } + AgentsOverviewAction::Delete => { + "Deleting task… You can still browse.".to_owned() + } + }) + }) .or_else(|| { (state.refresh_failed && !state.loading).then(|| "Error loading tasks".to_owned()) }) diff --git a/codex-rs/tui/src/app/agents_overview_action_progress_tests.rs b/codex-rs/tui/src/app/agents_overview_action_progress_tests.rs index 546b77fdab..3be55d62cc 100644 --- a/codex-rs/tui/src/app/agents_overview_action_progress_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_action_progress_tests.rs @@ -1,9 +1,10 @@ -//! Progress rendering and input isolation while lifecycle requests are pending. +//! The dashboard remains browsable while lifecycle requests hold the server lock. use super::*; -use crate::app::agents_overview::tests::overview_thread; use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot; -use codex_app_server_protocol::ThreadStatus; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_buf; +use crossterm::event::KeyCode; use futures::stream; use pretty_assertions::assert_eq; use ratatui::Terminal; @@ -14,78 +15,105 @@ use std::time::Duration; #[tokio::test] async fn modal_consumes_input_and_finishes_after_input_closes() -> color_eyre::Result<()> { - let mut tui = crate::tui::test_support::make_test_tui()?; - tui.pause_events(); - let (finished_input, wait_for_input) = tokio::sync::oneshot::channel(); - let mut finished_input = Some(finished_input); - let mut events = VecDeque::from([ - tui::TuiEvent::Key(crossterm::event::KeyCode::Esc.into()), - tui::TuiEvent::Paste("ignore this input".into()), - tui::TuiEvent::Draw, - tui::TuiEvent::Resize(ratatui::layout::Size::new(80, 24)), - tui::TuiEvent::FocusGained, - ]); - let events = stream::poll_fn(move |_| { - if let Some(event) = events.pop_front() { - Poll::Ready(Some(event)) - } else { - if let Some(finished_input) = finished_input.take() { - let _ = finished_input.send(()); - } - Poll::Ready(None) - } - }); - let result = tokio::time::timeout( - Duration::from_secs(5), - run_lifecycle_modal( - &mut tui, - AgentsOverviewAction::Archive, - &Paragraph::new("Agent command center"), - async { - wait_for_input.await.expect("all modal input was consumed"); - tokio::task::yield_now().await; - "operation finished" - }, - events, - ), - ) - .await??; - assert_eq!(result, "operation finished"); - Ok(()) -} - -#[tokio::test] -async fn lifecycle_progress_wraps_at_narrow_widths() { - let (app, _rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await; - let threads = ["First task", "Selected task", "Last task"] - .into_iter() - .enumerate() - .map(|(index, name)| { - overview_thread( - ThreadId::from_u128(index as u128 + 1), - /*parent_thread_id*/ None, - name, - ThreadStatus::Idle, - ) - }) - .collect(); - let backdrop = app.agents_overview_view(threads, Some(ThreadId::from_u128(/*value*/ 2))); - for (action, name) in [ + for (action, snapshot) in [ (AgentsOverviewAction::Archive, "archive_progress"), (AgentsOverviewAction::Delete, "delete_progress"), ] { - let progress = LifecycleProgress(action); - let progress = DialogOverlay { - backdrop: RenderableItem::Borrowed(&backdrop), - dialog: CenteredView(&progress), - }; - let mut terminal = Terminal::new(TestBackend::new(36, 24)).unwrap(); - terminal - .draw(|frame| progress.render(frame.area(), frame.buffer_mut())) - .unwrap(); - insta::assert_snapshot!( - name, - normalize_agent_center_snapshot(terminal.backend().to_string()) + let (mut app, mut rx, _) = crate::app::tests::make_test_app_with_channels().await; + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + for index in 1..=3 { + let id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + &app.config.codex_home, + &format!("2025-01-0{index}T12-00-00"), + &format!("2025-01-0{index}T12:00:00Z"), + &format!("Task {index}"), + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize task"), + )?; + let mut thread = server.thread_read(id, /*include_turns*/ false).await?; + thread.cwd = test_path_buf("/tmp/project").abs(); + app.agents_overview.threads.insert(id, Some(thread)); + } + let threads = app + .agents_overview + .threads + .values() + .flatten() + .cloned() + .collect(); + let view = app.agents_overview_view(threads, /*selected_thread_id*/ None); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let removed = app.agents_overview.visible_thread_ids[0]; + app.prepare_agents_overview_removal(&HashSet::from([removed])); + app.agents_overview.hidden_threads.insert(removed); + app.agents_overview.view_state.lock().unwrap().removal = Some(action); + app.repaint_agents_overview(); + assert!(!app.agents_overview.visible_thread_ids.contains(&removed)); + while rx.try_recv().is_ok() {} + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + let (finished_input, wait_for_input) = tokio::sync::oneshot::channel(); + let mut finished_input = Some(finished_input); + let mut events = VecDeque::from([ + tui::TuiEvent::Key(KeyCode::End.into()), + // Leaving and server mutations wait without accumulating actions. + tui::TuiEvent::Key(KeyCode::Esc.into()), + tui::TuiEvent::Key(KeyCode::Enter.into()), + tui::TuiEvent::Key(KeyCode::Char('n').into()), + tui::TuiEvent::Draw, + tui::TuiEvent::Resize(ratatui::layout::Size::new(66, 20)), + tui::TuiEvent::FocusGained, + ]); + let events = stream::poll_fn(move |_| { + if let Some(event) = events.pop_front() { + Poll::Ready(Some(event)) + } else { + if let Some(finished_input) = finished_input.take() { + let _ = finished_input.send(()); + } + Poll::Ready(None) + } + }); + let result = tokio::time::timeout( + Duration::from_secs(5), + run_lifecycle_modal( + &mut tui, + &mut app, + async { + wait_for_input.await.expect("all modal input was consumed"); + "operation finished" + }, + events, + ), + ) + .await??; + assert_eq!(result, "operation finished"); + assert_eq!( + tui.terminal.last_known_screen_size, + ratatui::layout::Size::new(66, 20) ); + let selected = app + .chat_widget + .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) + .expect("dashboard stays open"); + assert_eq!(selected, app.agents_overview.visible_thread_ids.len() - 1); + assert!( + !std::iter::from_fn(|| rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::SelectAgentsOverviewThread { .. } + | AppEvent::NewAgentsOverviewSession { .. } + )) + ); + let mut terminal = Terminal::new(TestBackend::new(80, 24))?; + terminal.draw(|frame| app.chat_widget.render(frame.area(), frame.buffer_mut()))?; + let text = normalize_agent_center_snapshot(terminal.backend().to_string()); + let age = regex_lite::Regex::new(r" +[0-9]+d ago").unwrap(); + insta::assert_snapshot!(snapshot, age.replace_all(&text, " [age]")); + server.shutdown().await?; } + Ok(()) } diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 9afbe1e269..782e78bba4 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -1,19 +1,16 @@ //! Server RPCs own lifecycle outcomes; successful actions invalidate dashboard group caches. -//! A modal keeps rendering during RPCs while deferring navigation and task notifications. +//! Keep the dashboard browsable during RPCs while deferring task notifications. //! Removing the current root leaves an unattached dashboard, even when it is empty. use super::App; +use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; -use crate::bottom_pane::BottomPaneView; -use crate::bottom_pane::CenteredView; -use crate::bottom_pane::DialogOverlay; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::chatwidget::ChatWidget; use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableItem; use crate::tui; use crate::wrapping::word_wrap_lines; use codex_app_server_protocol::ClientRequest; @@ -52,51 +49,14 @@ impl Renderable for LifecycleHeader { } } -struct LifecycleProgress(AgentsOverviewAction); - -impl LifecycleProgress { - fn header(&self) -> LifecycleHeader { - let title = match self.0 { - AgentsOverviewAction::Archive => "Archiving task…", - AgentsOverviewAction::Delete => "Deleting task…", - }; - LifecycleHeader(vec![ - title.bold().into(), - "Please wait. Task switching is unavailable until this finishes." - .dim() - .into(), - ]) - } -} - -impl Renderable for LifecycleProgress { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.header().render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - self.header().desired_height(width) - } -} - -impl BottomPaneView for LifecycleProgress {} - -/// Keep the terminal responsive without dispatching navigation or queued task events. +/// Keep browsing responsive without queuing actions against a locked server. async fn run_lifecycle_modal( tui: &mut tui::Tui, - action: AgentsOverviewAction, - backdrop: &dyn Renderable, + app: &mut App, operation: impl Future, events: impl Stream + Unpin, ) -> color_eyre::Result { - let progress = LifecycleProgress(action); - let progress = DialogOverlay { - backdrop: RenderableItem::Borrowed(backdrop), - dialog: CenteredView(&progress), - }; - tui.draw(u16::MAX, |frame| { - progress.render(frame.area(), frame.buffer_mut()); - })?; + app.render_chat_widget_frame(tui, tui.terminal.last_known_screen_size)?; let mut events = events.fuse(); tokio::pin!(operation); let result = loop { @@ -104,13 +64,28 @@ async fn run_lifecycle_modal( biased; result = &mut operation => break result, Some(event) = events.next() => { - tui.screen_size_for_event(&event)?; + let screen_size = tui.screen_size_for_event(&event)?; + if matches!(&event, tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost) { + app.cancel_pending_key_chord(); + } + let dashboard_active = app.chat_widget + .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some(); match event { + tui::TuiEvent::Key(key) if dashboard_active => { + if let Some(key) = app.route_key_chord_event(tui, key) { + app.chat_widget.handle_key_event_with_activation_key(key, app.routed_key_activation); + } + tui.frame_requester().schedule_frame(); + } + tui::TuiEvent::Paste(text) if dashboard_active => { + app.chat_widget.handle_paste(text); + tui.frame_requester().schedule_frame(); + } tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost | tui::TuiEvent::Mouse(_) => {} tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => { - tui.draw(u16::MAX, |frame| { - progress.render(frame.area(), frame.buffer_mut()); - })?; + app.expire_pending_key_chord(); + app.render_chat_widget_frame(tui, screen_size)?; } } } @@ -276,25 +251,26 @@ impl App { let mut removes_primary = self.primary_thread_id == Some(thread_id); let mut attempted = false; let primary_thread_id = self.primary_thread_id; - // Retain the displayed selection and shared scroll/filter state while the RPC - // borrows the live widget to stop voice. Re-render on resize rather than freezing pixels. - let selected_thread_id = self - .chat_widget - .selected_index_for_present_view(super::agents_overview::AGENTS_OVERVIEW_VIEW_ID) - .and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied()); - let threads = self - .agents_overview - .threads - .values() - .flatten() - .cloned() - .collect(); - let backdrop = self.agents_overview_view(threads, selected_thread_id); let events = tui.event_stream(); + self.prepare_agents_overview_removal(&HashSet::from([thread_id])); + let hidden = self.agents_overview.hidden_threads.insert(thread_id); + { + let mut state = self + .agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.removal = Some(action); + // Confirmation may have been opened from the rename editor. + state.rename_target = None; + } + self.repaint_agents_overview(); + let voice_result = self + .stop_voice_for_removed_thread(app_server, thread_id) + .await; let operation = async { let result = async { - self.stop_voice_for_removed_thread(app_server, thread_id) - .await?; + voice_result?; if let Some(primary) = primary_thread_id && primary != thread_id { @@ -335,8 +311,16 @@ impl App { } (result, detach_primary) }; - let (result, detach_primary) = - run_lifecycle_modal(tui, action, &backdrop, operation, events).await?; + let outcome = run_lifecycle_modal(tui, self, operation, events).await; + if hidden { + self.agents_overview.hidden_threads.remove(&thread_id); + } + self.agents_overview + .view_state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .removal = None; + let (result, detach_primary) = outcome?; if attempted { // A read started before the RPC can contain stale task and approval state. if let Some(refresh) = self.agents_overview.refresh_task.take() { @@ -398,6 +382,14 @@ impl App { } } if detach_primary { + self.agents_overview.selection_after_removal = + self.agents_overview.selection_after_removal.or_else(|| { + self.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .and_then(|index| { + self.agents_overview.visible_thread_ids.get(index).copied() + }) + }); self.reset_for_thread_switch(tui)?; self.pending_thread_switch_resets += 1; self.app_event_tx diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 4beec41237..22393ac97b 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -156,6 +156,8 @@ pub(super) struct AgentsOverviewViewState { pub(super) has_more: bool, show_more_selected: bool, pub(super) connection_notice: Option<&'static str>, + /// Browse only while the server holds its lifecycle lock. + pub(super) removal: Option, pub(super) server_version_notice: Option, search: String, searching: bool, @@ -588,6 +590,9 @@ impl BottomPaneView for AgentsOverviewView { self.reconcile_command_center_selection(); return CancellationEvent::Handled; } + if state.removal.is_some() { + return CancellationEvent::Handled; + } CancellationEvent::NotHandled } @@ -647,7 +652,9 @@ impl BottomPaneView for AgentsOverviewView { return; } - if (self.state().connection_notice.is_some() || self.state().creating_worktree) + if (self.state().connection_notice.is_some() + || self.state().creating_worktree + || self.state().removal.is_some()) && self.keymap.action_for(key) != Some(ListAction::Cancel) { match self.keymap.action_for(key) { @@ -656,6 +663,12 @@ impl BottomPaneView for AgentsOverviewView { Some(action @ (ListAction::PageUp | ListAction::PageDown)) => { self.page_selection(action) } + Some(ListAction::JumpTop) => { + self.selected = self.visible_indices().first().copied().unwrap_or(0); + } + Some(ListAction::JumpBottom) => { + self.selected = self.visible_indices().last().copied().unwrap_or(0); + } _ => {} } return; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap index ba3b35a524..b7d216eada 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap @@ -1,29 +1,28 @@ --- source: tui/src/app/agents_overview_action_progress_tests.rs -assertion_line: 86 -expression: normalize_agent_center_snapshot(terminal.backend().to_string()) +expression: "age.replace_all(&text, \" [age]\")" --- -" Agent command center Group: Pr… " -" All 3 Needs you 0 › " -" ──────────────────────────────── " -" Tasks " -" /tmp/project 3 " -" ○ First task " -" › ○ Selected task " -" ○ Last task " -" " -" " -" Archiving task… " -" Please wait. Task switching is " -" unavailable until this finishes. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" ? help esc back ↑/↓ move ent… " +" Agent command center Group: Project g " +" All 2 Needs you 0 Working 0 Ready 0 Inactive 2 " +" ──────────────────────────────────────────────────────────────────────────── " +" Tasks Status Updated " +" /tmp/project 2 " +" ○ Task 2 Inactive [age] " +" › ○ Task 1 Inactive [age] " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Archiving task… You can still browse. " diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap index 3ed43f3b66..10370ac2a4 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap @@ -1,29 +1,28 @@ --- source: tui/src/app/agents_overview_action_progress_tests.rs -assertion_line: 86 -expression: normalize_agent_center_snapshot(terminal.backend().to_string()) +expression: "age.replace_all(&text, \" [age]\")" --- -" Agent command center Group: Pr… " -" All 3 Needs you 0 › " -" ──────────────────────────────── " -" Tasks " -" /tmp/project 3 " -" ○ First task " -" › ○ Selected task " -" ○ Last task " -" " -" " -" Deleting task… " -" Please wait. Task switching is " -" unavailable until this finishes. " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" " -" ? help esc back ↑/↓ move ent… " +" Agent command center Group: Project g " +" All 2 Needs you 0 Working 0 Ready 0 Inactive 2 " +" ──────────────────────────────────────────────────────────────────────────── " +" Tasks Status Updated " +" /tmp/project 2 " +" ○ Task 2 Inactive [age] " +" › ○ Task 1 Inactive [age] " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" " +" Deleting task… You can still browse. "