mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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
This commit is contained in:
@@ -443,6 +443,10 @@ pub struct TuiAgentsKeymap {
|
||||
pub rename: Option<KeybindingsSpec>,
|
||||
/// Stop the selected running task.
|
||||
pub stop: Option<KeybindingsSpec>,
|
||||
/// Archive the selected task and its child agents after confirmation.
|
||||
pub archive: Option<KeybindingsSpec>,
|
||||
/// Permanently delete the selected task and its child agents after confirmation.
|
||||
pub delete: Option<KeybindingsSpec>,
|
||||
/// Hide the selected task until explicitly resumed or the TUI restarts.
|
||||
pub hide: Option<KeybindingsSpec>,
|
||||
/// Toggle grouping tasks by status or project.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<Line<'static>>);
|
||||
|
||||
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<T>(
|
||||
tui: &mut tui::Tui,
|
||||
action: AgentsOverviewAction,
|
||||
operation: impl Future<Output = T>,
|
||||
events: impl Stream<Item = tui::TuiEvent> + Unpin,
|
||||
) -> color_eyre::Result<T> {
|
||||
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;
|
||||
@@ -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<_>>(),
|
||||
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::<TurnStartResponse>(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::<ThreadId>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
app_server
|
||||
.thread_loaded_list(ThreadLoadedListParams::default())
|
||||
.await?
|
||||
.data,
|
||||
Vec::<String>::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::<String>()
|
||||
== key
|
||||
})
|
||||
.expect("footer shortcut");
|
||||
assert_eq!(
|
||||
cells.iter().map(|cell| cell.modifier).collect::<Vec<_>>(),
|
||||
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::<Vec<_>>();
|
||||
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()
|
||||
|
||||
@@ -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::<usize>()
|
||||
+ hints.len().saturating_sub(1) * 2
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+12
@@ -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
|
||||
+8
@@ -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. "
|
||||
+8
@@ -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. "
|
||||
@@ -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,
|
||||
|
||||
+29
-11
@@ -411,6 +411,8 @@ pub(crate) struct AgentsKeymap {
|
||||
pub(crate) new_task: Vec<KeyBinding>,
|
||||
pub(crate) rename: Vec<KeyBinding>,
|
||||
pub(crate) stop: Vec<KeyBinding>,
|
||||
pub(crate) archive: Vec<KeyBinding>,
|
||||
pub(crate) delete: Vec<KeyBinding>,
|
||||
pub(crate) hide: Vec<KeyBinding>,
|
||||
pub(crate) toggle_grouping: Vec<KeyBinding>,
|
||||
chord_hints: Arc<RuntimeChordKeymap>,
|
||||
@@ -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(),
|
||||
|
||||
@@ -398,6 +398,8 @@ define_runtime_action_bindings! {
|
||||
new_task,
|
||||
rename,
|
||||
stop,
|
||||
archive,
|
||||
delete,
|
||||
hide,
|
||||
toggle_grouping,
|
||||
],
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_snapshot!("keymap_picker_agents_actions", agents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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),
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+14
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user