From b348fc26674189f758d5941cdab3f78f258b2aa7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 10 Sep 2026 05:57:09 +0000 Subject: [PATCH] Add archive and delete actions to the agents overview (#44433) ## What changed - Add `Ctrl+E` to archive the selected task and its child agents, and `Delete` to permanently delete their history. Both actions require confirmation, with Cancel selected by default. - Expose `agents.archive` and `agents.delete` in keymap configuration and the shortcut picker. Default bindings yield to existing custom bindings and chords. - Show progress while requests run, keeping rendering responsive and blocking task switching. Refresh overview state afterward and keep the dashboard open when the current task is removed. - Report failures and preserve the current attachment and draft when a rejected operation leaves the task loaded. ## Testing Add coverage for confirmation and cancellation, active and background task removal, child-agent attachments, stale refresh invalidation, failure recovery, and draft preservation. Add progress input-isolation tests, shortcut conflict tests, and snapshots for confirmation, errors, progress, and keymap rendering. GitOrigin-RevId: b327bb8834699d0058f9ffb903ace525ae270500 --- codex-rs/config/src/tui_keymap.rs | 4 + codex-rs/core/config.schema.json | 20 + codex-rs/tui/src/app.rs | 1 + .../agents_overview_action_progress_tests.rs | 67 +++ .../tui/src/app/agents_overview_actions.rs | 322 +++++++++++++ .../src/app/agents_overview_actions_tests.rs | 449 +++++++++++++++++- .../tui/src/app/agents_overview_render.rs | 25 +- codex-rs/tui/src/app/agents_overview_tests.rs | 4 +- codex-rs/tui/src/app/agents_overview_view.rs | 16 + codex-rs/tui/src/app/event_dispatch.rs | 6 + ...tions__agents_custom_lifecycle_chords.snap | 2 +- ...s__actions__archive_task_confirmation.snap | 12 + ..._tests__actions__archive_task_failure.snap | 12 + ...ts__actions__delete_task_confirmation.snap | 12 + ...__tests__actions__delete_task_failure.snap | 12 + ...ons__progress_tests__archive_progress.snap | 8 + ...ions__progress_tests__delete_progress.snap | 8 + codex-rs/tui/src/app_event.rs | 17 + codex-rs/tui/src/keymap.rs | 40 +- codex-rs/tui/src/keymap/bindings.rs | 2 + codex-rs/tui/src/keymap/conflict_tests.rs | 45 +- codex-rs/tui/src/keymap_setup.rs | 20 + codex-rs/tui/src/keymap_setup/actions.rs | 4 + ...__tests__agents_overview_empty_narrow.snap | 3 +- ...__tests__keymap_picker_agents_actions.snap | 14 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 2 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- ...setup__tests__keymap_question_actions.snap | 2 +- 32 files changed, 1095 insertions(+), 46 deletions(-) create mode 100644 codex-rs/tui/src/app/agents_overview_action_progress_tests.rs create mode 100644 codex-rs/tui/src/app/agents_overview_actions.rs create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 4b532faa8a..b0fba20876 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -443,6 +443,10 @@ pub struct TuiAgentsKeymap { pub rename: Option, /// Stop the selected running task. pub stop: Option, + /// Archive the selected task and its child agents after confirmation. + pub archive: Option, + /// Permanently delete the selected task and its child agents after confirmation. + pub delete: Option, /// Hide the selected task until explicitly resumed or the TUI restarts. pub hide: Option, /// Toggle grouping tasks by status or project. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2d62cc765f..350723936d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3993,6 +3993,8 @@ ], "default": { "agents": { + "archive": null, + "delete": null, "hide": null, "new_task": null, "rename": null, @@ -4304,6 +4306,22 @@ "additionalProperties": false, "description": "Shortcuts specific to the shared agents overview.", "properties": { + "archive": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Archive the selected task and its child agents after confirmation." + }, + "delete": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Permanently delete the selected task and its child agents after confirmation." + }, "hide": { "allOf": [ { @@ -4817,6 +4835,8 @@ } ], "default": { + "archive": null, + "delete": null, "hide": null, "new_task": null, "rename": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 514ff8c269..4e4f534ca6 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_actions; mod agents_overview_details; mod agents_overview_threads; mod agents_overview_view; 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 new file mode 100644 index 0000000000..49ac829858 --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_action_progress_tests.rs @@ -0,0 +1,67 @@ +//! Progress rendering and input isolation while lifecycle requests are pending. + +use super::*; +use futures::stream; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use std::collections::VecDeque; +use std::task::Poll; +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, + 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(()) +} + +#[test] +fn lifecycle_progress_wraps_at_narrow_widths() { + for (action, name) in [ + (AgentsOverviewAction::Archive, "archive_progress"), + (AgentsOverviewAction::Delete, "delete_progress"), + ] { + let progress = LifecycleProgress(action); + let mut terminal = + Terminal::new(TestBackend::new(36, progress.desired_height(/*width*/ 36))).unwrap(); + terminal + .draw(|frame| progress.render(frame.area(), frame.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } +} diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs new file mode 100644 index 0000000000..8ecc6a51db --- /dev/null +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -0,0 +1,322 @@ +//! Server RPCs own lifecycle outcomes; successful actions invalidate dashboard group caches. +//! A modal keeps rendering during RPCs while deferring navigation and task notifications. +//! Removing the current root leaves an unattached dashboard, even when it is empty. + +use super::App; +use crate::app_event::AgentsOverviewAction; +use crate::app_event::AppEvent; +use crate::app_server_session::AppServerSession; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::chatwidget::ChatWidget; +use crate::render::renderable::Renderable; +use crate::tui; +use crate::wrapping::word_wrap_lines; +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use std::collections::HashSet; +use std::future::Future; +use tokio_stream::Stream; +use tokio_stream::StreamExt; + +struct LifecycleHeader(Vec>); + +impl Renderable for LifecycleHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + Renderable::render( + &Paragraph::new(word_wrap_lines(&self.0, usize::from(area.width))), + area, + buf, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + word_wrap_lines(&self.0, usize::from(width)).len() as u16 + } +} + +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) + } +} + +/// Keep the terminal responsive without dispatching navigation or queued task events. +async fn run_lifecycle_modal( + tui: &mut tui::Tui, + action: AgentsOverviewAction, + operation: impl Future, + events: impl Stream + Unpin, +) -> color_eyre::Result { + let progress = LifecycleProgress(action); + tui.draw(u16::MAX, |frame| { + progress.render(frame.area(), frame.buffer_mut()); + })?; + let mut events = events.fuse(); + tokio::pin!(operation); + let result = loop { + tokio::select! { + biased; + result = &mut operation => break result, + Some(event) = events.next() => { + tui.screen_size_for_event(&event)?; + match event { + tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost => {} + tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => { + tui.draw(u16::MAX, |frame| { + progress.render(frame.area(), frame.buffer_mut()); + })?; + } + } + } + } + }; + tui.frame_requester().schedule_frame(); + Ok(result) +} + +impl App { + pub(super) fn confirm_agents_overview_action( + &mut self, + thread_id: ThreadId, + action: AgentsOverviewAction, + ) { + let Some(thread) = self + .agents_overview + .threads + .get(&thread_id) + .and_then(Option::as_ref) + else { + return; + }; + let name = thread.name.as_deref().unwrap_or(&thread.preview); + let name = name + .trim() + .lines() + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Untitled task"); + let (title, description, label) = match action { + AgentsOverviewAction::Archive => ( + format!("Archive “{name}”?"), + "This stops any running work in this task and its child agents, then archives them. Their history can be restored from the resume picker.", + "Archive task and child agents", + ), + AgentsOverviewAction::Delete => ( + format!("Permanently delete “{name}”?"), + "This stops any running work in this task and its child agents, then permanently deletes their history. This cannot be undone.", + "Permanently delete task and child agents", + ), + }; + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(LifecycleHeader(vec![ + title.bold().into(), + description.dim().into(), + ])), + items: vec![ + SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: label.to_string(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::RunAgentsOverviewAction { thread_id, action }) + })], + dismiss_on_select: true, + require_explicit_confirmation: true, + ..Default::default() + }, + ], + ..Default::default() + }); + } + + pub(super) async fn run_agents_overview_action( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, + action: AgentsOverviewAction, + ) -> color_eyre::Result<()> { + // The overview may lack intermediate ancestors, or even the primary's metadata. + let mut removes_primary = self.primary_thread_id == Some(thread_id); + let mut attempted = false; + let primary_thread_id = self.primary_thread_id; + let events = tui.event_stream(); + let operation = async { + let result = async { + if let Some(primary) = primary_thread_id + && primary != thread_id + { + removes_primary = app_server + .thread_read(primary, /*include_turns*/ false) + .await? + .session_id + == thread_id.to_string(); + } + attempted = true; + match action { + AgentsOverviewAction::Archive => app_server.thread_archive(thread_id).await, + AgentsOverviewAction::Delete => app_server.thread_delete(thread_id).await, + } + } + .await; + let mut detach_primary = removes_primary && attempted; + if detach_primary + && result.is_err() + && let Some(primary) = primary_thread_id + { + // A rejected operation may have left the attachment alive. Ask the server directly. + let mut cursor = None; + while let Ok(page) = app_server + .thread_loaded_list(ThreadLoadedListParams { + cursor, + limit: None, + }) + .await + { + if page.data.contains(&primary.to_string()) { + detach_primary = false; + break; + } + let Some(next) = page.next_cursor else { break }; + cursor = Some(next); + } + } + (result, detach_primary) + }; + let (result, detach_primary) = run_lifecycle_modal(tui, action, operation, events).await?; + 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() { + refresh.abort(); + } + self.agents_overview.request_id = None; + self.agents_overview.refresh_pending = false; + self.agents_overview.refresh_notifications.clear(); + } + if result.is_ok() { + // Invalidate cached details for the displayed group, without inferring child outcomes. + let mut removed = HashSet::from([thread_id]); + loop { + let previous_len = removed.len(); + for (id, thread) in &self.agents_overview.threads { + let Some(thread) = thread else { continue }; + let parent = thread + .parent_thread_id + .as_deref() + .and_then(|id| ThreadId::from_string(id).ok()) + .or(match &thread.source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + .. + }) => Some(*parent_thread_id), + _ => None, + }); + if thread.session_id == thread_id.to_string() + || parent.is_some_and(|parent| removed.contains(&parent)) + { + removed.insert(*id); + } + } + if removed.len() == previous_len { + break; + } + } + if let Some(primary) = self.primary_thread_id + && removes_primary + { + removed.insert(primary); + } + for removed_id in removed { + self.agents_overview.threads.remove(&removed_id); + self.agents_overview.activity.remove(&removed_id); + self.agents_overview.last_messages.remove(&removed_id); + self.agents_overview.refresh_thread_ids.remove(&removed_id); + self.agents_overview.input_states.remove(&removed_id); + self.agents_overview.dispatched_requests.remove(&removed_id); + } + } + if detach_primary { + self.reset_for_thread_switch(tui)?; + self.pending_thread_switch_resets += 1; + self.app_event_tx + .send(AppEvent::ResetTranscriptForThreadSwitch); + self.reset_thread_event_state(); + let init = self.chatwidget_init_for_forked_or_resumed_thread( + tui, + self.config.clone(), + /*initial_user_message*/ None, + ); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); + self.open_agents_overview(app_server); + } else { + self.repaint_agents_overview(); + if attempted { + self.refresh_agents_overview_threads(app_server); + } + } + if let Err(error) = result { + let verb = match action { + AgentsOverviewAction::Archive => "archive", + AgentsOverviewAction::Delete => "delete", + }; + let mut header = vec![ + format!("Could not {verb} task").red().bold().into(), + format!("{error:#}").red().into(), + ]; + if attempted { + header.push( + "Work may have stopped. Resume the task to continue, or retry the action." + .dim() + .into(), + ); + } + self.chat_widget.show_selection_view(SelectionViewParams { + header: Box::new(LifecycleHeader(header)), + items: vec![SelectionItem { + name: "Back to agents".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }); + } + + Ok(()) + } +} + +#[cfg(test)] +#[path = "agents_overview_action_progress_tests.rs"] +mod progress_tests; 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 afe2b05588..c6dd7435cf 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -1,10 +1,22 @@ use super::*; +use crate::app_event::AgentsOverviewAction; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_model_provider_info::ModelProviderInfo; +use core_test_support::responses; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; use pretty_assertions::assert_eq; #[tokio::test] async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { let mut app = make_test_app().await; let mut keymap = TuiKeymap::default(); + keymap.agents.archive = Some(KeybindingsSpec::One(KeybindingSpec("f5".into()))); + keymap.agents.delete = Some(KeybindingsSpec::One(KeybindingSpec("f6".into()))); keymap.agents.hide = Some(KeybindingsSpec::One(KeybindingSpec("f7".into()))); app.keymap = RuntimeKeymap::from_config(&keymap).unwrap(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); @@ -39,6 +51,17 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() { for character in "Target".chars() { view.handle_key_event(KeyCode::Char(character).into()); } + for (key, expected) in [ + (5, AgentsOverviewAction::Archive), + (6, AgentsOverviewAction::Delete), + ] { + view.handle_key_event(KeyCode::F(key).into()); + let event = rx.try_recv(); + assert!( + matches!(event, Ok(AppEvent::ConfirmAgentsOverviewAction { thread_id, action }) if thread_id == target && action == expected), + "unexpected event: {event:?}" + ); + } view.handle_key_event(KeyCode::F(7).into()); assert!( matches!(rx.try_recv(), Ok(AppEvent::HideAgentsOverviewThread { thread_id }) if thread_id == target) @@ -129,12 +152,424 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum Ok(()) } +#[tokio::test] +async fn rejected_delete_preserves_a_live_attachment_and_draft() -> Result<()> { + let (mut app, _rx, _op_rx) = crate::app::tests::make_test_app_with_channels().await; + app.config.ephemeral = true; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let started = app_server.start_thread(&app.config).await?; + let id = started.session.thread_id; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.chat_widget + .apply_external_edit("Keep this draft".into()); + let draft = app.chat_widget.capture_thread_input_state(); + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + // Ephemeral tasks provide a deterministic rejection before server teardown. + Box::pin(app.run_agents_overview_action( + &mut tui, + &mut app_server, + id, + AgentsOverviewAction::Delete, + )) + .await?; + assert_eq!( + (app.primary_thread_id, app.chat_widget.thread_id()), + (Some(id), Some(id)) + ); + assert_eq!(app.chat_widget.capture_thread_input_state(), draft); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Could not delete task")); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashboard() -> Result<()> +{ + for (action, snapshot, attach_child) in [ + (AgentsOverviewAction::Archive, "archive_task", false), + (AgentsOverviewAction::Delete, "delete_task", false), + (AgentsOverviewAction::Archive, "archive_task", true), + (AgentsOverviewAction::Delete, "delete_task", true), + ] { + let key = match action { + AgentsOverviewAction::Archive => { + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL) + } + AgentsOverviewAction::Delete => KeyCode::Delete.into(), + }; + let (mut app, mut rx, _op_rx) = + Box::pin(crate::app::tests::make_test_app_with_channels()).await; + let (release, gate) = tokio::sync::oneshot::channel(); + let (server, _completions) = start_streaming_sse_server(vec![vec![ + StreamingSseChunk { + gate: None, + body: responses::sse(vec![responses::ev_response_created("running")]), + }, + StreamingSseChunk { + gate: Some(gate), + body: responses::sse(vec![responses::ev_completed("running")]), + }, + ]]) + .await; + app.config.model = Some("gpt-5.2".into()); + app.config.model_provider_id = "lifecycle-test".into(); + app.config.model_provider = ModelProviderInfo { + name: "Lifecycle test".into(), + base_url: Some(format!("{}/v1", server.uri())), + request_max_retries: Some(0), + stream_max_retries: Some(0), + ..ModelProviderInfo::default() + }; + app_test_support::MockResponsesConfig::new(server.uri()) + .with_model("gpt-5.2") + .with_model_provider("lifecycle-test") + .with_provider_name("Lifecycle test") + .write(app.config.codex_home.as_path())?; + let mut app_server = + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + &app.config.codex_home, + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Current task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize session"), + )?; + let primary = if attach_child { + let child = ThreadId::from_string( + &app_test_support::create_fake_parented_rollout_with_source( + &app.config.codex_home, + "2025-01-05T12-01-00", + "2025-01-05T12:01:00Z", + "Current child", + Some(&app.config.model_provider_id), + /*git_info*/ None, + codex_protocol::protocol::SessionSource::SubAgent( + SubAgentSource::ThreadSpawn { + parent_thread_id: id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }, + ), + codex_protocol::SessionId::from(id), + id, + ) + .expect("materialize child session"), + )?; + let state_db = codex_state::StateRuntime::init( + codex_state::SqliteConfig::new_for_testing(app.config.codex_home.clone()), + app.config.model_provider_id.clone(), + ) + .await + .expect("initialize spawn state"); + state_db + .upsert_thread_spawn_edge( + id, + child, + codex_state::DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + .expect("persist spawn edge"); + app.agents_overview.threads.insert( + child, + Some(overview_thread( + child, + Some(id), + "Current child", + ThreadStatus::Idle, + )), + ); + child + } else { + id + }; + let resumed = Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + primary, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; + app.enqueue_primary_thread_session(resumed.session, resumed.turns) + .await?; + app.agents_overview.threads.insert( + id, + Some(overview_thread( + id, + /*parent_thread_id*/ None, + "Current task", + ThreadStatus::Idle, + )), + ); + app.app_server_target = AppServerTarget::LocalDaemon { + endpoint: crate::RemoteAppServerEndpoint::UnixSocket { + socket_path: test_path_buf("/tmp/unused.sock").abs(), + }, + }; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + app.open_agents_overview(&app_server); + if action == AgentsOverviewAction::Archive { + let rollout = app_server + .thread_read(id, /*include_turns*/ false) + .await? + .path + .expect("saved root history"); + let blocked_archive = app + .config + .codex_home + .join("archived_sessions") + .join(rollout.file_name().unwrap()); + std::fs::create_dir_all(&blocked_archive)?; + Box::pin(app.run_agents_overview_action(&mut tui, &mut app_server, id, action)).await?; + assert_eq!( + (app.primary_thread_id, app.chat_widget.thread_id()), + (None, None) + ); + assert_eq!(app.agents_overview.visible_thread_ids, vec![id]); + assert_eq!( + app_server + .thread_read(primary, /*include_turns*/ false) + .await? + .status, + ThreadStatus::NotLoaded + ); + let error = render_bottom_popup(&app.chat_widget, /*width*/ 40); + assert!(error.contains("Could not archive task")); + assert!(error.contains("Work may have stopped.")); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + std::fs::remove_dir(&blocked_archive)?; + let resumed = Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + primary, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; + app.enqueue_primary_thread_session(resumed.session, resumed.turns) + .await?; + app.open_agents_overview(&app_server); + } + let background = ThreadId::from_string( + &app_test_support::create_fake_rollout( + &app.config.codex_home, + "2025-01-05T13-00-00", + "2025-01-05T13:00:00Z", + "Background task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize background session"), + )?; + let child = ThreadId::new(); + let grandchild = ThreadId::new(); + for (target, parent) in [ + (background, None), + (child, Some(background)), + (grandchild, Some(child)), + ] { + app.agents_overview.threads.insert( + target, + Some(overview_thread( + target, + parent, + "Background task", + ThreadStatus::NotLoaded, + )), + ); + } + // Cached details and approval state are invalidated as a group after success. + let request_id = Uuid::new_v4(); + app.agents_overview.request_id = Some(request_id); + let stale_threads = app.agents_overview.threads.clone(); + Box::pin(app.run_agents_overview_action(&mut tui, &mut app_server, background, action)) + .await?; + app.apply_agents_overview_thread_refresh( + &app_server, + request_id, + Ok(AgentsOverviewThreadRefresh { + threads: stale_threads, + last_messages: HashMap::new(), + recent_seed_complete: true, + }), + ); + assert_eq!( + (app.primary_thread_id, app.chat_widget.thread_id()), + (Some(primary), Some(primary)) + ); + assert_eq!(app.agents_overview.visible_thread_ids, vec![id]); + assert_eq!( + app.agents_overview + .threads + .keys() + .copied() + .collect::>(), + std::collections::HashSet::from([id, primary]) + ); + Box::pin(app.run_agents_overview_action( + &mut tui, + &mut app_server, + ThreadId::from_string("00000000-0000-0000-0000-000000000001")?, + action, + )) + .await?; + assert_eq!( + (app.primary_thread_id, app.chat_widget.thread_id()), + (Some(primary), Some(primary)) + ); + insta::assert_snapshot!( + format!("{snapshot}_failure"), + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app_server + .request_handle() + .request_typed::(ClientRequest::TurnStart { + request_id: RequestId::String(Uuid::new_v4().to_string()), + params: TurnStartParams { + thread_id: primary.to_string(), + input: vec![codex_app_server_protocol::UserInput::Text { + text: "Keep working".into(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + tokio::time::timeout( + std::time::Duration::from_secs(/*secs*/ 5), + server.wait_for_request_count(/*count*/ 1), + ) + .await?; + assert!(matches!( + app_server + .thread_read(primary, /*include_turns*/ false) + .await? + .status, + ThreadStatus::Active { .. } + )); + app.chat_widget.handle_key_event(key); + let confirmation = std::iter::from_fn(|| rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ConfirmAgentsOverviewAction { .. })) + .expect("shortcut requests confirmation"); + Box::pin(app.handle_event(&mut tui, &mut app_server, confirmation)).await?; + insta::assert_snapshot!( + format!("{snapshot}_confirmation"), + render_bottom_popup(&app.chat_widget, /*width*/ 72) + ); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + assert!( + !std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::RunAgentsOverviewAction { .. })) + ); + app.chat_widget.handle_key_event(key); + let confirmation = std::iter::from_fn(|| rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ConfirmAgentsOverviewAction { .. })) + .expect("shortcut requests confirmation again"); + Box::pin(app.handle_event(&mut tui, &mut app_server, confirmation)).await?; + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let confirmed = std::iter::from_fn(|| rx.try_recv().ok()) + .find(|event| matches!(event, AppEvent::RunAgentsOverviewAction { .. })) + .expect("confirmation requests lifecycle action"); + if attach_child { + // Removal must not depend on the overview having the primary or its ancestors cached. + app.agents_overview.threads.remove(&primary); + } + Box::pin(app.handle_event(&mut tui, &mut app_server, confirmed)).await?; + assert_eq!( + ( + app.primary_thread_id, + app.current_displayed_thread_id(), + app.chat_widget.thread_id() + ), + (None, None, None) + ); + assert_eq!( + app.agents_overview.visible_thread_ids, + Vec::::new() + ); + assert_eq!( + app_server + .thread_loaded_list(ThreadLoadedListParams::default()) + .await? + .data, + Vec::::new() + ); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + match action { + AgentsOverviewAction::Archive => { + app_server.thread_unarchive(id).await?; + } + AgentsOverviewAction::Delete => { + assert!( + app_server + .thread_read(id, /*include_turns*/ false) + .await + .is_err() + ); + } + } + app_server.shutdown().await?; + drop(release); + server.shutdown().await; + } + Ok(()) +} + +#[tokio::test] +async fn disabled_footer_shortcuts_stay_bold_when_wrapped() { + let app = make_test_app().await; + let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + view.handle_key_event(KeyCode::Esc.into()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 84, /*height*/ 24, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + view.render(area, &mut buffer); + let delete_key = crate::key_hint::plain(KeyCode::Delete).display_label(); + for key in ["ctrl+x", "ctrl+w", "ctrl+e", delete_key.as_str()] { + let cells = buffer + .content() + .windows(key.len()) + .find(|cells| { + cells + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + == key + }) + .expect("footer shortcut"); + assert_eq!( + cells.iter().map(|cell| cell.modifier).collect::>(), + vec![ratatui::style::Modifier::BOLD | ratatui::style::Modifier::DIM; key.len()] + ); + } +} + #[tokio::test] async fn lifecycle_footer_keeps_custom_chords_with_labels() { let mut app = make_test_app().await; app.keymap = RuntimeKeymap::from_config( &serde_json::from_value(serde_json::json!({ - "agents": { "hide": "f5 f8" } + "agents": { "archive": "f5 f6", "delete": "f5 f7", "hide": "f5 f8" } })) .unwrap(), ) @@ -164,11 +599,13 @@ async fn lifecycle_footer_keeps_custom_chords_with_labels() { .to_string() }) .collect::>(); - assert!( - lines - .iter() - .any(|line| line.contains("hide") && line.contains("f5")) - ); + for label in ["archive", "delete", "hide"] { + assert!( + lines + .iter() + .any(|line| line.contains(label) && line.contains("f5")) + ); + } assert!( lines .iter() diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index 036f0c5e45..a01b482468 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -25,6 +25,8 @@ impl AgentsOverviewView { &self.agents_keymap.new_task, &self.agents_keymap.rename, &self.agents_keymap.stop, + &self.agents_keymap.archive, + &self.agents_keymap.delete, &self.agents_keymap.hide, &self.agents_keymap.toggle_grouping, ] @@ -56,7 +58,11 @@ impl AgentsOverviewView { let key = hint.display_label().replace(" + ", "+"); hints.push( vec![ - if enabled { key.bold() } else { key.dim() }, + if enabled { + key.bold() + } else { + key.bold().dim() + }, format!(" {label}").dim(), ] .into(), @@ -105,12 +111,17 @@ impl AgentsOverviewView { self.selected_row() .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })), ); - add_hint( - self.agents_keymap - .primary_hint("hide", &self.agents_keymap.hide), - "hide", - self.selected_row().is_some(), - ); + for (action, bindings) in [ + ("hide", &self.agents_keymap.hide), + ("archive", &self.agents_keymap.archive), + ("delete", &self.agents_keymap.delete), + ] { + add_hint( + self.agents_keymap.primary_hint(action, bindings), + action, + self.selected_row().is_some(), + ); + } add_hint(list_hint(ListAction::Cancel), "back", true); let separator = if hints.iter().map(Line::width).sum::() + hints.len().saturating_sub(1) * 2 diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 0219a57531..f018402b26 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -2037,8 +2037,10 @@ async fn empty_command_center_can_open_resume_picker() { event_rx.try_recv(), Ok(AppEvent::OpenResumePicker) )); + // Crossterm labels forward Delete as "fwd del" on macOS and "del" elsewhere. + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 48).replace("fwd del", "del"); insta::with_settings!({snapshot_path => "../snapshots"}, { - insta::assert_snapshot!("agents_overview_empty_narrow", render_bottom_popup(&app.chat_widget, /*width*/ 48)); + insta::assert_snapshot!("agents_overview_empty_narrow", rendered); }); } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 9b042d8fea..2032af45c0 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -7,6 +7,7 @@ mod input; mod render; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; +use crate::app_event::AgentsOverviewAction; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPaneView; @@ -689,6 +690,21 @@ impl BottomPaneView for AgentsOverviewView { } return; } + for (bindings, action) in [ + (&self.agents_keymap.archive, AgentsOverviewAction::Archive), + (&self.agents_keymap.delete, AgentsOverviewAction::Delete), + ] { + if bindings.is_pressed(key) { + if let Some(row) = self.selected_row() { + self.app_event_tx + .send(AppEvent::ConfirmAgentsOverviewAction { + thread_id: row.thread_id, + action, + }); + } + return; + } + } if self.agents_keymap.hide.is_pressed(key) { if let Some(row) = self.selected_row() { self.app_event_tx.send(AppEvent::HideAgentsOverviewThread { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 60088fe4bd..bcd076f26f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2807,6 +2807,12 @@ impl App { self.agents_overview.hidden_threads.insert(thread_id); self.repaint_agents_overview(); } + AppEvent::ConfirmAgentsOverviewAction { thread_id, action } => { + self.confirm_agents_overview_action(thread_id, action); + } + AppEvent::RunAgentsOverviewAction { thread_id, action } => { + self.run_agents_overview_action(tui, app_server, thread_id, action).await?; + } AppEvent::StopAgentsOverviewThread { thread_id } => { self.stop_agents_overview_thread(app_server, thread_id) .await; diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap index c24bd213a9..8e272d5b0b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__agents_custom_lifecycle_chords.snap @@ -25,4 +25,4 @@ expression: "render_bottom_popup(&app.chat_widget, 48)" ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop f5 f8 hide - esc back + f5 f6 archive f5 f7 delete esc back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap new file mode 100644 index 0000000000..9808a756b8 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 72)" +--- + Archive “Current task”? + This stops any running work in this task and its child agents, then + archives them. Their history can be restored from the resume picker. + +› 1. Cancel + 2. Archive task and child agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap new file mode 100644 index 0000000000..7556b48701 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__archive_task_failure.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Could not archive task + failed to archive session: thread/archive failed: no rollout found for + thread id 00000000-0000-0000-0000-000000000001 (code -32600) + Work may have stopped. Resume the task to continue, or retry the action. + +› 1. Back to agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap new file mode 100644 index 0000000000..226b8a0592 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 72)" +--- + Permanently delete “Current task”? + This stops any running work in this task and its child agents, then + permanently deletes their history. This cannot be undone. + +› 1. Cancel + 2. Permanently delete task and child agents + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap new file mode 100644 index 0000000000..d6f2795757 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_failure.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_actions_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 80)" +--- + Could not delete task + failed to delete session: thread/delete failed: no rollout found for thread + id 00000000-0000-0000-0000-000000000001 (code -32600) + Work may have stopped. Resume the task to continue, or retry the action. + +› 1. Back to agents + + Press enter to confirm or esc to go back 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 new file mode 100644 index 0000000000..d6bd0e16e7 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__archive_progress.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_action_progress_tests.rs +assertion_line: 62 +expression: terminal.backend() +--- +"Archiving task… " +"Please wait. Task switching is " +"unavailable until this finishes. " 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 new file mode 100644 index 0000000000..2a2f6f66c3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview_actions__progress_tests__delete_progress.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/agents_overview_action_progress_tests.rs +assertion_line: 62 +expression: terminal.backend() +--- +"Deleting task… " +"Please wait. Task switching is " +"unavailable until this finishes. " diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 20ca588c66..7611d89cf5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -67,6 +67,13 @@ use codex_realtime_webrtc::StartedRealtimeWebrtcSession; use crate::history_cell::HistoryCell; +/// Confirmed server lifecycle operations available from the agents dashboard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AgentsOverviewAction { + Archive, + Delete, +} + /// Whether a managed checkout starts fresh or preserves the current conversation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ManagedWorktreeMode { @@ -314,6 +321,16 @@ pub(crate) enum AppEvent { HideAgentsOverviewThread { thread_id: ThreadId, }, + /// Confirm a server lifecycle action for the selected dashboard task. + ConfirmAgentsOverviewAction { + thread_id: ThreadId, + action: AgentsOverviewAction, + }, + /// Execute a confirmed dashboard lifecycle action. + RunAgentsOverviewAction { + thread_id: ThreadId, + action: AgentsOverviewAction, + }, /// Start the shared app-server daemon without moving the current embedded session. #[cfg(any(unix, windows))] StartAgentsDaemon, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 4f8bb1ace0..f3b85c456f 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -411,6 +411,8 @@ pub(crate) struct AgentsKeymap { pub(crate) new_task: Vec, pub(crate) rename: Vec, pub(crate) stop: Vec, + pub(crate) archive: Vec, + pub(crate) delete: Vec, pub(crate) hide: Vec, pub(crate) toggle_grouping: Vec, chord_hints: Arc, @@ -1290,22 +1292,34 @@ impl RuntimeKeymap { new_task: resolve_local!(keymap, defaults, agents, new_task), rename: resolve_local!(keymap, defaults, agents, rename), stop: resolve_local!(keymap, defaults, agents, stop), + archive: resolve_local!(keymap, defaults, agents, archive), + delete: resolve_local!(keymap, defaults, agents, delete), hide: resolve_local!(keymap, defaults, agents, hide), toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping), chord_hints: Arc::clone(&chords), }; - // The new default yields to existing user bindings in the dashboard. - if keymap.agents.hide.is_none() - && (configured_context_alias_is_used(&keymap.agents, "ctrl-w") - || configured_context_alias_is_used(&keymap.list, "ctrl-w") - || configured_context_alias_is_used(&keymap.global, "ctrl-w") - || chords.bindings.iter().any(|chord| { - chord.action.context.overlaps(KeymapContext::Agents) - && agents.hide.contains(&chord.chord.prefix) - })) - { - agents.hide.clear(); + // Newly added defaults yield to existing user bindings in the dashboard. + for (configured, bindings, alias) in [ + ( + keymap.agents.archive.as_ref(), + &mut agents.archive, + "ctrl-e", + ), + (keymap.agents.delete.as_ref(), &mut agents.delete, "delete"), + (keymap.agents.hide.as_ref(), &mut agents.hide, "ctrl-w"), + ] { + if configured.is_none() + && (configured_context_alias_is_used(&keymap.agents, alias) + || configured_context_alias_is_used(&keymap.list, alias) + || configured_context_alias_is_used(&keymap.global, alias) + || chords.bindings.iter().any(|chord| { + chord.action.context.overlaps(KeymapContext::Agents) + && bindings.contains(&chord.chord.prefix) + })) + { + bindings.clear(); + } } let approval = ApprovalKeymap { @@ -1440,6 +1454,8 @@ impl RuntimeKeymap { (keymap.agents.new_task.as_ref(), &mut agents.new_task), (keymap.agents.rename.as_ref(), &mut agents.rename), (keymap.agents.stop.as_ref(), &mut agents.stop), + (keymap.agents.archive.as_ref(), &mut agents.archive), + (keymap.agents.delete.as_ref(), &mut agents.delete), (keymap.agents.hide.as_ref(), &mut agents.hide), ( keymap.agents.toggle_grouping.as_ref(), @@ -1805,6 +1821,8 @@ impl RuntimeKeymap { new_task: default_bindings![ctrl(KeyCode::Char('n'))], rename: default_bindings![ctrl(KeyCode::Char('r'))], stop: default_bindings![ctrl(KeyCode::Char('x'))], + archive: default_bindings![ctrl(KeyCode::Char('e'))], + delete: default_bindings![plain(KeyCode::Delete)], hide: default_bindings![ctrl(KeyCode::Char('w'))], toggle_grouping: default_bindings![ctrl(KeyCode::Char('s'))], chord_hints: Arc::default(), diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index e65c0bc885..30785ac858 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -398,6 +398,8 @@ define_runtime_action_bindings! { new_task, rename, stop, + archive, + delete, hide, toggle_grouping, ], diff --git a/codex-rs/tui/src/keymap/conflict_tests.rs b/codex-rs/tui/src/keymap/conflict_tests.rs index 2d3ad6a0d9..45fce93f6f 100644 --- a/codex-rs/tui/src/keymap/conflict_tests.rs +++ b/codex-rs/tui/src/keymap/conflict_tests.rs @@ -40,16 +40,39 @@ See the Codex keymap documentation for supported actions and examples." #[test] fn new_agents_defaults_preserve_existing_custom_bindings() { - for context in ["agents", "list"] { - let existing = if context == "agents" { - "stop" - } else { - "move_down" - }; - let keymap: TuiKeymap = - serde_json::from_value(json!({context: {existing: "ctrl-w"}})).unwrap(); - let runtime = - RuntimeKeymap::from_config(&keymap).expect("existing configuration remains valid"); - assert!(runtime.agents.hide.is_empty()); + for (action, alias) in [ + ("archive", "ctrl-e"), + ("delete", "delete"), + ("hide", "ctrl-w"), + ] { + for (context, existing, suffix) in [ + ("agents", "stop", ""), + ("list", "move_down", ""), + ("global", "copy", ""), + ("agents", "stop", " f12"), + ("list", "move_down", " f12"), + ] { + // Ctrl+E is reserved by the resume picker for list-context chords. + if context == "list" && alias == "ctrl-e" && !suffix.is_empty() { + continue; + } + let keymap: TuiKeymap = serde_json::from_value( + json!({context: {existing: format!("{alias}{suffix}")}, "approval": {"open_fullscreen": "f12"}, + "editor": {"move_line_start": [], "move_line_end": [], "delete_backward_word": [], "delete_forward": []}}), + ) + .unwrap(); + let runtime = + RuntimeKeymap::from_config(&keymap).expect("existing configuration remains valid"); + let bindings = match action { + "archive" => &runtime.agents.archive, + "delete" => &runtime.agents.delete, + "hide" => &runtime.agents.hide, + _ => unreachable!(), + }; + assert!( + bindings.is_empty(), + "{action} shadows {context}.{existing} = {alias}{suffix}" + ); + } } } diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index bdfd96e3d5..a2b5edc3bb 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -1017,6 +1017,26 @@ mod tests { .join("\n"); assert_snapshot!("keymap_picker_first_actions", snapshot); + let agents = all_tab + .items + .iter() + .filter(|item| { + item.search_value + .as_deref() + .unwrap_or_default() + .starts_with("Agents ") + }) + .map(|item| { + format!( + "{} | {} | {}", + item.name, + item.description.as_deref().unwrap_or_default(), + item.search_value.as_deref().unwrap_or_default() + ) + }) + .collect::>() + .join("\n"); + assert_snapshot!("keymap_picker_agents_actions", agents); } #[test] diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 7d1818df6a..761677d653 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -218,6 +218,8 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("agents", "Agents", "new_task", "Start composing a new agent task."), action("agents", "Agents", "rename", "Rename the selected task."), action("agents", "Agents", "stop", "Stop the selected running task."), + action("agents", "Agents", "archive", "Archive the selected task and its child agents."), + action("agents", "Agents", "delete", "Permanently delete the selected task and its child agents."), action("agents", "Agents", "hide", "Hide the selected task until explicitly resumed."), action("agents", "Agents", "toggle_grouping", "Group tasks by status or project."), action("approval", "Approval", "open_fullscreen", "Open approval details fullscreen."), @@ -396,6 +398,8 @@ pub(super) fn binding_slot<'a>( ("agents", "new_task") => Some(&mut keymap.agents.new_task), ("agents", "rename") => Some(&mut keymap.agents.rename), ("agents", "stop") => Some(&mut keymap.agents.stop), + ("agents", "archive") => Some(&mut keymap.agents.archive), + ("agents", "delete") => Some(&mut keymap.agents.delete), ("agents", "hide") => Some(&mut keymap.agents.hide), ("agents", "toggle_grouping") => Some(&mut keymap.agents.toggle_grouping), ("approval", "open_fullscreen") => Some(&mut keymap.approval.open_fullscreen), diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap index dbe862b406..b1aac35b4e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap @@ -1,5 +1,6 @@ --- source: tui/src/app/agents_overview_tests.rs +assertion_line: 2041 expression: "render_bottom_popup(&app.chat_widget, 48)" --- Agent command center @@ -25,4 +26,4 @@ expression: "render_bottom_popup(&app.chat_widget, 48)" ↑↓ navigate ctrl+o resume → open ctrl+n new task ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop ctrl+w hide - esc back + ctrl+e archive del delete esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap new file mode 100644 index 0000000000..47a1d310cf --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_agents_actions.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/keymap_setup.rs +assertion_line: 1039 +expression: agents +--- +Resume | ctrl-o | Agents resume Resume Open the session resume picker. ctrl-o Default +Search | ctrl-f | Agents search Search Search the available agent tasks. ctrl-f Default +New Task | ctrl-n | Agents new_task New Task Start composing a new agent task. ctrl-n Default +Rename | ctrl-r | Agents rename Rename Rename the selected task. ctrl-r Default +Stop | ctrl-x | Agents stop Stop Stop the selected running task. ctrl-x Default +Archive | ctrl-e | Agents archive Archive Archive the selected task and its child agents. ctrl-e Default +Delete | delete | Agents delete Delete Permanently delete the selected task and its child agents. delete Default +Hide | ctrl-w | Agents hide Hide Hide the selected task until explicitly resumed. ctrl-w Default +Toggle Grouping | ctrl-s | Agents toggle_grouping Toggle Grouping Group tasks by status or project. ctrl-s Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index c0618d4648..a5a09e4e96 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 143 actions, 1 customized, 5 unbound. + 145 actions, 1 customized, 5 unbound. [All] Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index c3660558b4..6163a996aa 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 144 actions, 0 customized, 6 unbound. + 146 actions, 0 customized, 6 unbound. [All] Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index f605a3d19a..76535cd4da 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,7 +2,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (143 selectable) +tab: All (145 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) @@ -11,7 +11,7 @@ tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) tab: Navigation (20 selectable) -tab: Agents (7 selectable) +tab: Agents (9 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) Open Agents | unbound | Global open_agents Open Agents Open the shared agent-session overview. unbound Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index 6a25089305..1ce9c49f8c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 143 actions, 1 customized, 6 unbound. + 145 actions, 1 customized, 6 unbound. [All] Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index b3598ab122..4c6d34eed2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 143 actions, 0 customized, 5 unbound. + 145 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index eb492abd0b..1c72983594 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 143 actions, 0 customized, 5 unbound. + 145 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap index b1fe73575c..b16b7e0fc9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_question_actions.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 143 actions, 0 customized, 5 unbound. + 145 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug