From 1d741742c5fd5d3cf5d93ea71d48a3c9af9bda41 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 3 Sep 2026 00:39:05 +0000 Subject: [PATCH] Add session resume to the agent command center (#42419) ## What changed - Add a configurable `agents.resume` action, bound to `Ctrl+O` by default, that opens the session resume picker from the agent command center. - Preserve command-center state when the picker is cancelled, and preserve per-thread composer and queued-input state when switching sessions. - Keep resume failures within the command-center flow and wrap footer shortcuts on narrow terminals. ## Testing - Add coverage for picker cancellation, active-session selection, resume failures, per-thread input restoration, custom keybindings, and narrow footer layout. GitOrigin-RevId: 59159f838a0c642200cd164e3af45b0971d29bf8 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/app.rs | 1 + .../tui/src/app/agents_overview_render.rs | 193 +++++----- codex-rs/tui/src/app/agents_overview_tests.rs | 343 +++++++++++++++++- codex-rs/tui/src/app/agents_overview_view.rs | 17 +- codex-rs/tui/src/app/event_dispatch.rs | 56 +-- codex-rs/tui/src/app/resume_config.rs | 6 +- codex-rs/tui/src/app/session_lifecycle.rs | 4 +- codex-rs/tui/src/app/session_picker.rs | 142 ++++++++ .../tui/src/app/tests/disconnect_tests.rs | 7 +- ...daemon_command_center_vanished_rename.snap | 4 +- codex-rs/tui/src/keymap.rs | 37 ++ codex-rs/tui/src/keymap/bindings.rs | 1 + codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...ents_overview__tests__agents_overview.snap | 4 +- ...__tests__agents_overview_empty_narrow.snap | 28 ++ ...__tests__agents_overview_resume_error.snap | 10 + ...setup__tests__keymap_debug_view_match.snap | 1 + ...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 +- 25 files changed, 712 insertions(+), 170 deletions(-) create mode 100644 codex-rs/tui/src/app/session_picker.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index fd37e9f820..ecf963fe32 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -429,6 +429,8 @@ pub struct TuiListKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiAgentsKeymap { + /// Open the session resume picker. + pub resume: Option, /// Search the available agent tasks. pub search: Option, /// Start composing a new agent task. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 33e37a89b1..d2bd3b9db0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3858,6 +3858,7 @@ "agents": { "new_task": null, "rename": null, + "resume": null, "search": null, "stop": null, "toggle_grouping": null @@ -4164,6 +4165,14 @@ ], "description": "Rename the selected task." }, + "resume": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the session resume picker." + }, "search": { "allOf": [ { @@ -4631,6 +4640,7 @@ "default": { "new_task": null, "rename": null, + "resume": null, "search": null, "stop": null, "toggle_grouping": null diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9881c0e03b..ff2e7f8142 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -237,6 +237,7 @@ mod resize_reflow; mod resume_config; mod safety_buffering; mod session_lifecycle; +mod session_picker; mod side; mod startup; mod startup_prompts; diff --git a/codex-rs/tui/src/app/agents_overview_render.rs b/codex-rs/tui/src/app/agents_overview_render.rs index 5ec8b722ff..96949b4f8b 100644 --- a/codex-rs/tui/src/app/agents_overview_render.rs +++ b/codex-rs/tui/src/app/agents_overview_render.rs @@ -1,7 +1,106 @@ //! Layout rendering and cursor placement for the agent dashboard. +//! The prompt and its cursor reserve the same height for wrapped footer hints. use super::*; +impl AgentsOverviewView { + fn footer_lines(&self, width: u16) -> Vec> { + if self.state().connection_notice.is_some() { + return vec![ + "ctrl+c quit · actions paused until the list is refreshed" + .dim() + .into(), + ]; + } + let list_hint = |action| { + self.keymap.primary_hint(action).filter(|hint| { + !matches!(hint, ShortcutHint::Single(binding) + if is_plain_text_key_event(KeyEvent::new( + binding.parts().0, + binding.parts().1, + )) || [ + &self.agents_keymap.resume, + &self.agents_keymap.search, + &self.agents_keymap.new_task, + &self.agents_keymap.rename, + &self.agents_keymap.stop, + &self.agents_keymap.toggle_grouping, + ] + .into_iter() + .any(|bindings| bindings.contains(binding))) + }) + }; + let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown] + .into_iter() + .filter_map(list_hint) + .map(|hint| hint.display_label().replace(" + ", "+")) + .collect::>(); + let navigation_hint = navigation_hints.join( + if navigation_hints + .iter() + .all(|hint| hint.chars().count() == 1) + { + "" + } else { + " " + }, + ); + let mut footer_spans = Vec::new(); + if !navigation_hint.is_empty() { + footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]); + } + let mut add_hint = |hint: Option, label: &'static str, enabled: bool| { + if let Some(hint) = hint { + let key = hint.display_label().replace(" + ", "+"); + footer_spans.push(if enabled { key.bold() } else { key.dim() }); + footer_spans.push(format!(" {label} ").dim()); + } + }; + add_hint( + self.agents_keymap + .primary_hint("resume", &self.agents_keymap.resume), + "resume", + true, + ); + add_hint(list_hint(ListAction::Accept), "open", true); + add_hint( + self.agents_keymap + .primary_hint("search", &self.agents_keymap.search), + "search", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping), + "group", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("rename", &self.agents_keymap.rename), + "rename", + true, + ); + add_hint( + self.agents_keymap + .primary_hint("stop", &self.agents_keymap.stop), + "stop", + self.selected_row() + .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })), + ); + add_hint(list_hint(ListAction::Cancel), "back", true); + let mut footer_line: Line = footer_spans.into(); + if footer_line.width() > usize::from(width) { + for span in &mut footer_line.spans { + if span.content.ends_with(" ") { + span.content.to_mut().pop(); + } + } + } + crate::wrapping::word_wrap_lines([footer_line], usize::from(width)) + } +} + impl Renderable for AgentsOverviewView { fn desired_height(&self, _width: u16) -> u16 { 24 @@ -20,7 +119,9 @@ impl Renderable for AgentsOverviewView { .x .saturating_add((label.width() + input.width()) as u16) .min(area.right().saturating_sub(3)); - Some((x, area.bottom().saturating_sub(2))) + let footer_height = (self.footer_lines(area.width.saturating_sub(4)).len() as u16) + .min(area.height.saturating_sub(7)); + Some((x, area.bottom().saturating_sub(footer_height + 1))) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -28,13 +129,15 @@ impl Renderable for AgentsOverviewView { return; } Clear.render(area, buf); + let footer_lines = self.footer_lines(area.width.saturating_sub(4)); + let footer_height = (footer_lines.len() as u16).min(area.height.saturating_sub(7)); let [header, summary, divider, body, prompt, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), - Constraint::Length(1), + Constraint::Length(footer_height), ]) .areas(area); let inset = @@ -105,90 +208,6 @@ impl Renderable for AgentsOverviewView { let input = &input[visible_start..]; Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()]) .render(inset(prompt), buf); - if state.connection_notice.is_some() { - Line::from("ctrl+c quit · actions paused until the list is refreshed".dim()) - .render(inset(footer), buf); - return; - } - let list_hint = |action| { - self.keymap.primary_hint(action).filter(|hint| { - !matches!(hint, ShortcutHint::Single(binding) - if is_plain_text_key_event(KeyEvent::new( - binding.parts().0, - binding.parts().1, - )) || [ - &self.agents_keymap.search, - &self.agents_keymap.new_task, - &self.agents_keymap.rename, - &self.agents_keymap.stop, - &self.agents_keymap.toggle_grouping, - ] - .into_iter() - .any(|bindings| bindings.contains(binding))) - }) - }; - let navigation_hints = [ListAction::MoveUp, ListAction::MoveDown] - .into_iter() - .filter_map(list_hint) - .map(|hint| hint.display_label().replace(" + ", "+")) - .collect::>(); - let navigation_hint = navigation_hints.join( - if navigation_hints - .iter() - .all(|hint| hint.chars().count() == 1) - { - "" - } else { - " " - }, - ); - let mut footer_spans = Vec::new(); - if !navigation_hint.is_empty() { - footer_spans.extend([navigation_hint.bold(), " navigate ".dim()]); - } - let mut add_hint = |hint: Option, label: &'static str, enabled: bool| { - if let Some(hint) = hint { - let key = hint.display_label().replace(" + ", "+"); - footer_spans.push(if enabled { key.bold() } else { key.dim() }); - footer_spans.push(format!(" {label} ").dim()); - } - }; - add_hint(list_hint(ListAction::Accept), "open", true); - add_hint( - self.agents_keymap - .primary_hint("search", &self.agents_keymap.search), - "search", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("toggle_grouping", &self.agents_keymap.toggle_grouping), - "group", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("rename", &self.agents_keymap.rename), - "rename", - true, - ); - add_hint( - self.agents_keymap - .primary_hint("stop", &self.agents_keymap.stop), - "stop", - self.selected_row() - .is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. })), - ); - add_hint(list_hint(ListAction::Cancel), "back", true); - let footer_area = inset(footer); - let mut footer_line: Line = footer_spans.into(); - if footer_line.width() > usize::from(footer_area.width) { - for span in &mut footer_line.spans { - if span.content.ends_with(" ") { - span.content.to_mut().pop(); - } - } - } - footer_line.render(footer_area, buf); + Paragraph::new(footer_lines).render(inset(footer), buf); } } diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 9628367a52..a86c1f242a 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -199,7 +199,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res )?); } let config = app.config.clone(); - let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; for thread_id in [ids[0], ids[21]] { app_server .resume_thread( @@ -284,15 +284,14 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res "[tui]\nresume_cwd = \"session\"\n", )?; let mut tui = crate::tui::test_support::make_test_tui()?; - app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2]) - .await?; + Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2])).await?; assert_eq!(app.primary_thread_id, Some(ids[2])); app_server.shutdown().await?; // A fresh TUI/server has no in-memory additions; read-only resumes did not promote history. let mut restarted = make_test_app().await; restarted.app_server_target = app.app_server_target.clone(); - let app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); restarted.refresh_agents_overview_threads(&app_server); @@ -672,6 +671,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { let mut keymap = TuiKeymap::default(); keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string()))); keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string()))); + keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string()))); app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); let first = ThreadId::new(); let second = ThreadId::new(); @@ -709,6 +709,12 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { assert!(view.handle_paste("Do not dispatch this draft".to_string())); view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); assert!(view.handle_paste("Second task".to_string())); + view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::OpenResumePicker) + )); + assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)); assert!(matches!( event_rx.try_recv(), @@ -1135,3 +1141,332 @@ async fn restored_server_permission_profile_survives_cd_without_turn_override() app_server.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> { + for primary_thread_id in [None, Some(ThreadId::new())] { + let mut app = make_test_app().await; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); + app.primary_thread_id = primary_thread_id; + let threads = ["First task", "Second task"].map(|name| { + overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + name, + ThreadStatus::Idle, + ) + }); + let selected = ThreadId::from_string(&threads[1].id).unwrap(); + let view = app.agents_overview_view(threads.into(), Some(selected)); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + for key in [ + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE), + ] { + app.chat_widget.handle_key_event(key); + } + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + let selection = app + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); + while event_rx.try_recv().is_ok() {} + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::OpenResumePicker) + )); + assert!(event_rx.try_recv().is_err()); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + for selection in [SessionSelection::StartFresh, SessionSelection::Exit] { + assert!(matches!( + app.apply_resume_picker_selection(&mut tui, &mut server, selection) + .await?, + AppRunControl::Continue + )); + app.chat_widget.pre_draw_tick(); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); + } + server.shutdown().await?; + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + selection + ); + assert_eq!(app.agents_overview.view_state.lock().unwrap().input, "d"); + } + Ok(()) +} + +#[tokio::test] +async fn command_center_cursor_tracks_wrapped_footer() { + let app = make_test_app().await; + let mut view = app.agents_overview_view( + vec![overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + "Task", + ThreadStatus::Idle, + )], + /*selected_thread_id*/ None, + ); + for (key, label) in [('n', "New task ›"), ('f', "Search ›"), ('r', "Rename ›")] { + view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + for width in [48, 96, 120] { + let area = + ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 24); + let mut buffer = ratatui::buffer::Buffer::empty(area); + view.render(area, &mut buffer); + let prompt_row = buffer + .content() + .chunks(usize::from(width)) + .position(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .contains(label) + }) + .expect("rendered prompt"); + assert_eq!( + view.cursor_pos(area).map(|(_, y)| usize::from(y)), + Some(prompt_row) + ); + } + } +} + +#[tokio::test] +async fn empty_command_center_can_open_resume_picker() { + let mut app = make_test_app().await; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + while event_rx.try_recv().is_ok() {} + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::OpenResumePicker) + )); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_empty_narrow", render_bottom_popup(&app.chat_widget, /*width*/ 48)); + }); +} + +#[tokio::test] +async fn resuming_active_session_closes_command_center() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + app.primary_thread_id = Some(thread_id); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + assert!(matches!( + app.apply_resume_picker_selection( + &mut tui, + &mut server, + SessionSelection::Resume(SessionTarget { + path: None, + thread_id, + history_mode: None, + }) + ) + .await?, + AppRunControl::Continue + )); + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + None + ); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn resume_failure_keeps_command_center_available() { + let mut app = make_test_app().await; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let before = render_bottom_popup(&app.chat_widget, /*width*/ 96); + app.add_session_picker_error("The session is unavailable.".to_string()); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_resume_error", render_bottom_popup(&app.chat_widget, /*width*/ 96)); + }); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before); +} + +#[tokio::test] +async fn resume_picker_round_trip_preserves_each_threads_input() -> Result<()> { + let mut app = make_test_app().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + let mut targets = Vec::new(); + for (timestamp, name) in [ + ("2025-01-05T12-00-00", "First task"), + ("2025-01-05T13-00-00", "Second task"), + ] { + let id = app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + timestamp, + "2025-01-05T12:00:00Z", + name, + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("saved rollout"); + targets.push(SessionTarget { + path: Some(app_test_support::rollout_path( + app.config.codex_home.as_path(), + timestamp, + &id, + )), + thread_id: ThreadId::from_string(&id)?, + history_mode: None, + }); + } + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let mut expected_states = Vec::new(); + for target in targets.iter().chain(&targets) { + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.apply_resume_picker_selection( + &mut tui, + &mut server, + SessionSelection::Resume(target.clone()), + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(target.thread_id)); + if expected_states.len() < targets.len() { + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + assert!(app.chat_widget.queued_user_message_texts().is_empty()); + app.chat_widget.handle_server_notification( + ServerNotification::TurnStarted( + codex_app_server_protocol::TurnStartedNotification { + thread_id: target.thread_id.to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn-with-follow-up".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ), + /*replay_kind*/ None, + ); + let follow_up = format!("Follow-up for {}", target.thread_id); + app.chat_widget.apply_external_edit(follow_up.clone()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.chat_widget.queued_user_message_texts(), vec![follow_up]); + app.chat_widget + .apply_external_edit(format!("Draft for {}", target.thread_id)); + let mut input_state = app.chat_widget.capture_thread_input_state().unwrap(); + // Recovered follow-ups stay paused until the user explicitly submits them. + input_state.recovered_queue = true; + app.chat_widget.restore_thread_input_state( + Some(input_state), + crate::chatwidget::ThreadInputStateRestoreMode { + preserve_in_flight_turn: false, + }, + ); + expected_states.push(app.chat_widget.capture_thread_input_state()); + } else { + let index = targets + .iter() + .position(|t| t.thread_id == target.thread_id) + .unwrap(); + assert_eq!( + app.chat_widget.capture_thread_input_state(), + expected_states[index] + ); + } + } + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn command_center_handles_resume_failure_and_success() -> Result<()> { + let mut app = make_test_app().await; + std::fs::write( + app.config.codex_home.join("config.toml"), + "[tui]\nresume_cwd = \"current\"\n", + )?; + let timestamp = "2025-01-05T12-00-00"; + let id = app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + timestamp, + "2025-01-05T12:00:00Z", + "Saved task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("saved rollout"); + let thread_id = ThreadId::from_string(&id)?; + let path = app_test_support::rollout_path(app.config.codex_home.as_path(), timestamp, &id); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + assert!(matches!( + app.apply_resume_picker_selection( + &mut tui, + &mut server, + SessionSelection::Resume(SessionTarget { + path: None, + thread_id: ThreadId::new(), + history_mode: None, + }) + ) + .await?, + AppRunControl::Continue + )); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + assert!( + render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Unable to resume session") + ); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + app.apply_resume_picker_selection( + &mut tui, + &mut server, + SessionSelection::Resume(SessionTarget { + path: Some(path), + thread_id, + history_mode: None, + }) + ) + .await?, + AppRunControl::Continue + )); + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + None + ); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 85fdc5325f..20d20884b1 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -93,6 +93,8 @@ pub(super) struct AgentsOverviewViewState { searching: bool, pub(super) status_grouping: bool, pub(super) renaming: bool, + // The picker can finish this retained view when it selects the already active session. + pub(super) completion: Option, } pub(super) struct AgentsOverviewView { @@ -100,7 +102,6 @@ pub(super) struct AgentsOverviewView { selected: usize, state: Arc>, exit_on_cancel: bool, - completion: Option, app_event_tx: AppEventSender, keymap: ListKeymap, agents_keymap: AgentsKeymap, @@ -124,11 +125,11 @@ impl AgentsOverviewView { selected, state, exit_on_cancel, - completion: None, app_event_tx, keymap: keymap.list, agents_keymap: keymap.agents, }; + view.state().completion = None; let visible = view.visible_indices(); if !visible.contains(&view.selected) { view.selected = visible.first().copied().unwrap_or(usize::MAX); @@ -234,7 +235,7 @@ impl AgentsOverviewView { state.search.clear(); state.searching = false; } - self.completion = Some(ViewCompletion::Accepted); + self.state().completion = Some(ViewCompletion::Accepted); } } @@ -416,11 +417,11 @@ impl BottomPaneView for AgentsOverviewView { } fn completion(&self) -> Option { - self.completion + self.state().completion } fn is_complete(&self) -> bool { - self.completion.is_some() + self.completion().is_some() } fn prefer_esc_to_handle_key_event(&self) -> bool { @@ -475,6 +476,10 @@ impl BottomPaneView for AgentsOverviewView { return; } + if self.agents_keymap.resume.is_pressed(key) { + self.app_event_tx.send(AppEvent::OpenResumePicker); + return; + } if self.agents_keymap.toggle_grouping.is_pressed(key) { let mut state = self.state(); state.status_grouping = !state.status_grouping; @@ -541,7 +546,7 @@ impl BottomPaneView for AgentsOverviewView { self.app_event_tx .send(AppEvent::Exit(crate::app::ExitMode::Immediate)); } - self.completion = Some(ViewCompletion::Cancelled); + state.completion = Some(ViewCompletion::Cancelled); } } ListAction::PageUp | ListAction::PageDown => { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 310eb462e1..57fcbf38cf 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -238,61 +238,7 @@ impl App { .await; } AppEvent::OpenResumePicker => { - let picker_app_server = match crate::start_app_server_for_picker( - &self.config, - &self.app_server_target, - self.state_db.clone(), - self.environment_manager.clone(), - ) - .await - { - Ok(app_server) => app_server, - Err(err) => { - self.chat_widget.add_error_message(format!( - "Failed to start TUI session picker: {err}" - )); - self.chat_widget.maybe_send_next_queued_input(); - return Ok(AppRunControl::Continue); - } - }; - match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( - tui, - &self.config, - &self.local_settings, - /*show_all*/ false, - /*include_non_interactive*/ false, - picker_app_server, - app_server.request_handle(), - self.primary_thread_id - .or(self.current_displayed_thread_id()), - ) - .await? - { - SessionSelection::Resume(target_session) => { - match self - .resume_target_session(tui, app_server, target_session) - .await? - { - AppRunControl::Continue => {} - AppRunControl::Exit(reason) => { - return Ok(AppRunControl::Exit(reason)); - } - } - } - SessionSelection::Exit - | SessionSelection::StartFresh - | SessionSelection::AgentsOverview => { - self.refresh_in_memory_config_from_disk_best_effort( - "closing the session picker", - ) - .await; - } - SessionSelection::Fork(_) => {} - } - - self.chat_widget.maybe_send_next_queued_input(); - // Leaving alt-screen may blank the inline viewport; force a redraw either way. - tui.frame_requester().schedule_frame(); + return self.open_resume_picker(tui, app_server).await; } AppEvent::OpenExternalAgentConfigMigration => { match crate::external_agent_config_migration::flow::handle_external_agent_config_migration_prompt( diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index 96a735e7e7..f5e0b2bdd2 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -38,7 +38,7 @@ impl App { && app_server.remote_cwd_override().is_none() && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) { - self.chat_widget.add_error_message( + self.add_session_picker_error( "`tui.resume_cwd = \"current\"` requires `--cd` when using a remote workspace" .to_string(), ); @@ -64,7 +64,7 @@ impl App { .await; match outcome { Err(err) => { - self.chat_widget.add_error_message(format!( + self.add_session_picker_error(format!( "Failed to determine working directory for resume: {err}" )); return Err(AppRunControl::Continue); @@ -91,7 +91,7 @@ impl App { { Ok(cfg) => cfg, Err(err) => { - self.chat_widget.add_error_message(format!( + self.add_session_picker_error(format!( "Failed to rebuild configuration for resume: {err}" )); return Err(AppRunControl::Continue); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index b006a8ec2d..7dcb1b657b 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1158,7 +1158,7 @@ impl App { .await; } Err(err) => { - self.chat_widget.add_error_message(format!( + self.add_session_picker_error(format!( "Failed to attach to resumed app-server thread: {err}" )); } @@ -1166,7 +1166,7 @@ impl App { } Err(err) => { let path_display = target_session.display_label(); - self.chat_widget.add_error_message(format!( + self.add_session_picker_error(format!( "Failed to resume session from {path_display}: {err}" )); } diff --git a/codex-rs/tui/src/app/session_picker.rs b/codex-rs/tui/src/app/session_picker.rs new file mode 100644 index 0000000000..a1227f31cf --- /dev/null +++ b/codex-rs/tui/src/app/session_picker.rs @@ -0,0 +1,142 @@ +//! In-app session picker transitions, preserving per-thread input and the view on cancellation. + +use super::*; +use crate::chatwidget::ThreadInputStateRestoreMode; + +impl App { + pub(super) async fn open_resume_picker( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result { + let picker_app_server = match crate::start_app_server_for_picker( + &self.config, + &self.app_server_target, + self.state_db.clone(), + self.environment_manager.clone(), + ) + .await + { + Ok(app_server) => app_server, + Err(err) => { + self.add_session_picker_error(format!("Failed to start TUI session picker: {err}")); + self.chat_widget.maybe_send_next_queued_input(); + return Ok(AppRunControl::Continue); + } + }; + let selection = + crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( + tui, + &self.config, + &self.local_settings, + /*show_all*/ false, + /*include_non_interactive*/ false, + picker_app_server, + app_server.request_handle(), + self.primary_thread_id + .or(self.current_displayed_thread_id()), + ) + .await; + match selection { + Ok(selection) => { + self.apply_resume_picker_selection(tui, app_server, selection) + .await + } + Err(err) => { + self.add_session_picker_error(format!("Failed to open session picker: {err}")); + self.chat_widget.maybe_send_next_queued_input(); + Ok(AppRunControl::Continue) + } + } + } + + pub(super) async fn apply_resume_picker_selection( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + selection: SessionSelection, + ) -> Result { + match selection { + SessionSelection::Resume(target_session) => { + let thread_id = target_session.thread_id; + let switching_threads = self.current_displayed_thread_id() != Some(thread_id); + if switching_threads { + for (id, channel) in &self.thread_event_channels { + if let Some(input_state) = channel.store.lock().await.input_state.clone() { + self.agents_overview.input_states.insert(*id, input_state); + } + } + if let Some(current_thread_id) = self.current_displayed_thread_id() + && let Some(input_state) = self.chat_widget.capture_thread_input_state() + { + self.agents_overview + .input_states + .insert(current_thread_id, input_state); + } + } + if let AppRunControl::Exit(reason) = self + .resume_target_session(tui, app_server, target_session) + .await? + { + return Ok(AppRunControl::Exit(reason)); + } + if switching_threads + && self.current_displayed_thread_id() == Some(thread_id) + && let Some(input_state) = self.agents_overview.input_states.remove(&thread_id) + { + let preserve_in_flight_turn = + self.active_turn_id_for_thread(thread_id).await.is_some(); + self.chat_widget.restore_thread_input_state( + Some(input_state), + ThreadInputStateRestoreMode { + preserve_in_flight_turn, + }, + ); + } + if self.active_thread_id == Some(thread_id) + && self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.completion = Some(crate::bottom_pane::ViewCompletion::Accepted); + } + self.chat_widget.pre_draw_tick(); + } + } + SessionSelection::Exit + | SessionSelection::StartFresh + | SessionSelection::AgentsOverview => { + self.refresh_in_memory_config_from_disk_best_effort("closing the session picker") + .await; + } + SessionSelection::Fork(_) => {} + } + + self.chat_widget.maybe_send_next_queued_input(); + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + Ok(AppRunControl::Continue) + } + + pub(super) fn add_session_picker_error(&mut self, message: String) { + if self + .chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Unable to resume session".to_string()), + subtitle: Some(message.clone()), + items: vec![SelectionItem { + name: "Return to command center".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + ..Default::default() + }); + } + self.chat_widget.add_error_message(message); + } +} diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 68fee463da..50a35e8a80 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -218,6 +218,7 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ] { app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) .await?; @@ -235,8 +236,10 @@ async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result< "task draft!" ); assert!( - !std::iter::from_fn(|| events.try_recv().ok()) - .any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) + !std::iter::from_fn(|| events.try_recv().ok()).any(|event| matches!( + event, + AppEvent::DispatchAgentsOverviewTask { .. } | AppEvent::OpenResumePicker + )) ); assert_snapshot!( "offline_command_center", diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap index 0fadcdd1e2..835c3d0873 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__daemon_command_center_vanished_rename.snap @@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget, 100)" │ │ │ - │ New task › Describe a task and press enter to dispatch it - ↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back + ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc + back diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index edf521a9d2..f2e0509f69 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -386,6 +386,7 @@ impl ListKeymap { /// Task-management shortcuts specific to the shared agents dashboard. #[derive(Clone, Debug)] pub(crate) struct AgentsKeymap { + pub(crate) resume: Vec, pub(crate) search: Vec, pub(crate) new_task: Vec, pub(crate) rename: Vec, @@ -1198,7 +1199,20 @@ impl RuntimeKeymap { chord_hints: Arc::clone(&chords), }; + let resume_default_is_shadowed = keymap.agents.resume.is_none() + && (configured_context_alias_is_used(&keymap.agents, "ctrl-o") + || configured_context_alias_is_used(&keymap.list, "ctrl-o") + || chords.bindings.iter().any(|binding| { + binding.action.context.overlaps(KeymapContext::Agents) + && binding.chord.prefix.parts() + == key_hint::ctrl(KeyCode::Char('o')).parts() + })); let mut agents = AgentsKeymap { + resume: if resume_default_is_shadowed { + Vec::new() + } else { + resolve_local!(keymap, defaults, agents, resume) + }, search: resolve_local!(keymap, defaults, agents, search), new_task: resolve_local!(keymap, defaults, agents, new_task), rename: resolve_local!(keymap, defaults, agents, rename), @@ -1334,6 +1348,7 @@ impl RuntimeKeymap { }; for (configured, bindings) in [ + (keymap.agents.resume.as_ref(), &mut agents.resume), (keymap.agents.search.as_ref(), &mut agents.search), (keymap.agents.new_task.as_ref(), &mut agents.new_task), (keymap.agents.rename.as_ref(), &mut agents.rename), @@ -1693,6 +1708,7 @@ impl RuntimeKeymap { chord_hints: Arc::default(), }, agents: AgentsKeymap { + resume: default_bindings![ctrl(KeyCode::Char('o'))], search: default_bindings![ctrl(KeyCode::Char('f'))], new_task: default_bindings![ctrl(KeyCode::Char('n'))], rename: default_bindings![ctrl(KeyCode::Char('r'))], @@ -3310,6 +3326,7 @@ mod tests { keymap.global.open_agents = Some(one("f12")); keymap.agents.search = Some(one("f6")); keymap.agents.new_task = Some(one("f7")); + keymap.agents.resume = Some(one("f5")); keymap.agents.rename = Some(one("f9")); keymap.agents.stop = Some(one("f10")); keymap.agents.toggle_grouping = Some(one("f8")); @@ -3320,6 +3337,7 @@ mod tests { runtime.app.open_agents, runtime.agents.search, runtime.agents.new_task, + runtime.agents.resume, runtime.agents.rename, runtime.agents.stop, runtime.agents.toggle_grouping, @@ -3328,12 +3346,17 @@ mod tests { vec![key_hint::plain(KeyCode::F(12))], vec![key_hint::plain(KeyCode::F(6))], vec![key_hint::plain(KeyCode::F(7))], + vec![key_hint::plain(KeyCode::F(5))], vec![key_hint::plain(KeyCode::F(9))], vec![key_hint::plain(KeyCode::F(10))], vec![key_hint::plain(KeyCode::F(8))], ) ); + keymap.agents.resume = Some(one("f6")); + expect_conflict(&keymap, "resume", "search"); + keymap.agents.resume = Some(one("f5")); + keymap.agents.toggle_grouping = Some(one("right")); let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); assert!( @@ -3428,6 +3451,20 @@ mod tests { ); } + #[test] + fn agents_resume_default_yields_to_existing_custom_shortcuts() { + for binding in ["ctrl-o", "ctrl-o f6"] { + let mut keymap = TuiKeymap::default(); + keymap.agents.search = Some(one(binding)); + let runtime = + RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid"); + assert!(runtime.agents.resume.is_empty()); + + keymap.agents.resume = Some(one("ctrl-o")); + assert!(RuntimeKeymap::from_config(&keymap).is_err()); + } + } + #[test] fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 149f392d26..d6062db1e6 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -383,6 +383,7 @@ define_runtime_action_bindings! { cancel, ], "agents" => Agents, agents, agents [ + resume, search, new_task, rename, diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 252ba22a9b..0a03f5a4e8 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -210,6 +210,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("list", "List", "jump_bottom", "Jump to the last list item."), action("list", "List", "accept", "Accept the current list selection."), action("list", "List", "cancel", "Cancel and close selection views."), + action("agents", "Agents", "resume", "Open the session resume picker."), action("agents", "Agents", "search", "Search the available agent tasks."), action("agents", "Agents", "new_task", "Start composing a new agent task."), action("agents", "Agents", "rename", "Rename the selected task."), @@ -383,6 +384,7 @@ pub(super) fn binding_slot<'a>( ("list", "jump_bottom") => Some(&mut keymap.list.jump_bottom), ("list", "accept") => Some(&mut keymap.list.accept), ("list", "cancel") => Some(&mut keymap.list.cancel), + ("agents", "resume") => Some(&mut keymap.agents.resume), ("agents", "search") => Some(&mut keymap.agents.search), ("agents", "new_task") => Some(&mut keymap.agents.new_task), ("agents", "rename") => Some(&mut keymap.agents.rename), diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap index 5b81642a76..83d04e8e2b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview.snap @@ -23,6 +23,6 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj │ │ │ - │ New task › Describe a task and press enter to dispatch it - ↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back + ↑↓ navigate ctrl+o resume enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop + esc back 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 new file mode 100644 index 0000000000..25e7df76b3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_empty_narrow.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 48)" +--- + Agent command center + 0 need input 0 working 0 ready + ──────────────────────────────────────────── + + + + + + + + + + + + + + + + + + New task › Describe a task and press enter t + ↑↓ navigate ctrl+o resume enter open ctrl+f + search ctrl+s group ctrl+r rename ctrl+x + stop esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap new file mode 100644 index 0000000000..4a29582d7d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_resume_error.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 96)" +--- + Unable to resume session + The session is unavailable. + +› 1. Return to command center + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap index 95c9a47866..989945991e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap @@ -12,3 +12,4 @@ Raw event: code=Char('o'), modifiers=ctrl, kind=Press Assigned actions: - global.copy (Copy) - Copy the last agent response to the clipboard. [Default] + - agents.resume (Resume) - Open the session resume picker. [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 843da6280a..ba77790d89 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. - 138 actions, 1 customized, 5 unbound. + 139 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 0e5ba353d9..ccdaa5454e 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. - 139 actions, 0 customized, 6 unbound. + 140 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 514b5612a7..7ada04922f 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 (138 selectable) +tab: All (139 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 (5 selectable) +tab: Agents (6 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 fe855dcb35..bb56e5f919 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. - 138 actions, 1 customized, 5 unbound. + 139 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_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 79c49208fc..5cdf11fc77 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. - 138 actions, 0 customized, 5 unbound. + 139 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 8488ff6496..2028d5e993 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. - 138 actions, 0 customized, 5 unbound. + 139 actions, 0 customized, 5 unbound. [All] Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval Debug