From a62e98d18c6550e3bea152ed1b89d1e931dca961 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 10 Sep 2026 00:55:26 +0000 Subject: [PATCH] Return focus to the agents overview composer on Escape (#44360) ## Why Pressing `Esc` in the agents overview task list dismissed the overview or exited Codex when no primary thread existed, instead of returning to the draft. ## What changed Make list cancellation return focus to the composer, including while reconnecting, preserving the draft and cursor position. ## Testing Add regression coverage for returning from the list to the composer with Vim mode enabled or disabled, both online and reconnecting. Verify draft and cursor preservation, resumed text entry, and the absence of app events. GitOrigin-RevId: 8920677c42b0c9ff5023979e198f069abcfcf54b --- codex-rs/tui/src/app/agents_overview.rs | 1 - codex-rs/tui/src/app/agents_overview_tests.rs | 69 +++++++++++++++++-- codex-rs/tui/src/app/agents_overview_view.rs | 13 ++-- .../app/tests/navigation_reconnect_tests.rs | 6 ++ 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index b0ef6e2d19..cb1ebc9252 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -284,7 +284,6 @@ impl App { AgentsOverviewView::new( rows, selected_thread_id, - self.primary_thread_id.is_none(), self.config.features.enabled(Feature::Worktrees) && !crate::uses_remote_workspace_or_environment( &self.app_server_target, diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 182b26af6c..e2bff9b91d 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -59,6 +59,7 @@ async fn older_server_notice_falls_back_in_short_overview() { .collect::(); insta::assert_snapshot!(header.trim_end(), @" Old srv"); } +use crate::app::agents_overview_view::AgentsOverviewFocus; use crate::app::test_support::make_test_app; use crate::app_event::AgentsOverviewThreadRefresh; use crate::bottom_pane::BottomPaneView; @@ -106,6 +107,66 @@ fn overview_draft(app: &App) -> (String, usize) { (composer.current_text_with_pending(), composer.cursor()) } +#[tokio::test] +async fn overview_escape_returns_from_list_to_composer() { + for (vim, offline) in [(false, false), (true, false), (false, true), (true, true)] { + let mut app = make_test_app().await; + app.config.disable_paste_burst = true; + if vim { + app.chat_widget.toggle_vim_mode_and_notify(); + } + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let view = app.agents_overview_view( + vec![overview_thread( + ThreadId::new(), + /*parent_thread_id*/ None, + "Existing task", + ThreadStatus::Idle, + )], + /*selected_thread_id*/ None, + ); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.chat_widget.handle_paste("task draft".into()); + app.chat_widget.handle_key_event(KeyCode::Home.into()); + let draft = overview_draft(&app); + if offline { + app.agents_overview + .view_state + .lock() + .unwrap() + .connection_notice = Some("Reconnecting"); + } + if vim { + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + } + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + AgentsOverviewFocus::List + )); + app.chat_widget.handle_key_event(KeyCode::Down.into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + + assert!(matches!( + app.agents_overview.view_state.lock().unwrap().focus, + AgentsOverviewFocus::Composer + )); + assert_eq!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID), + Some(0) + ); + assert_eq!(overview_draft(&app), draft); + assert!(rx.try_recv().is_err()); + if !vim && !offline { + insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(), @" enter create task ctrl+j newline esc tasks"); + } + app.chat_widget.handle_key_event(KeyCode::Char('!').into()); + assert_eq!(overview_draft(&app).0, "!task draft"); + } +} + #[tokio::test] async fn overview_right_opens_current_or_highlighted_task() { let mut app = make_test_app().await; @@ -658,7 +719,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); finish_overview_refresh(&mut app, &app_server, &mut event_rx).await; let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect(); assert_eq!(retained, expected); @@ -1164,7 +1225,6 @@ async fn shared_overview_shows_only_root_sessions() { let mut action_view = AgentsOverviewView::new( view.rows.clone(), Some(first_root), - /*exit_on_cancel*/ false, /*worktrees_enabled*/ false, crate::app_event_sender::AppEventSender::new(event_tx), app.keymap.clone(), @@ -1282,7 +1342,7 @@ async fn shared_overview_shows_only_root_sessions() { app.chat_widget .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let view = app.agents_overview_view(threads, /*selected_thread_id*/ None); app.chat_widget.show_bottom_pane_view(Box::new(view)); let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96); @@ -1304,7 +1364,7 @@ async fn shared_overview_shows_only_root_sessions() { .expect("render full-screen dashboard"); assert_eq!(tui.terminal.viewport_area.height, screen_size.height); app.chat_widget - .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); app.render_chat_widget_frame(&mut tui, screen_size) .expect("restore conversation after closing dashboard"); assert!(tui.terminal.viewport_area.height < screen_size.height); @@ -1406,7 +1466,6 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() { ) .rows, Some(first), - /*exit_on_cancel*/ false, /*worktrees_enabled*/ false, crate::app_event_sender::AppEventSender::new(event_tx), app.keymap.clone(), diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index d89b8263ee..5db8cad719 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -180,7 +180,6 @@ pub(super) struct AgentsOverviewView { project_groups: Vec, selected: usize, state: Arc>, - exit_on_cancel: bool, app_event_tx: AppEventSender, keymap: ListKeymap, agents_keymap: AgentsKeymap, @@ -192,7 +191,6 @@ impl AgentsOverviewView { pub(super) fn new( rows: Vec, selected_thread_id: Option, - exit_on_cancel: bool, worktrees_enabled: bool, app_event_tx: AppEventSender, keymap: RuntimeKeymap, @@ -223,7 +221,6 @@ impl AgentsOverviewView { project_groups, selected, state, - exit_on_cancel, app_event_tx, keymap: keymap.list, agents_keymap: keymap.agents, @@ -650,7 +647,9 @@ impl BottomPaneView for AgentsOverviewView { return; } - if self.state().connection_notice.is_some() && !self.agents_keymap.new_task.is_pressed(key) + if self.state().connection_notice.is_some() + && !self.agents_keymap.new_task.is_pressed(key) + && self.keymap.action_for(key) != Some(ListAction::Cancel) { match self.keymap.action_for(key) { Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false), @@ -727,11 +726,7 @@ impl BottomPaneView for AgentsOverviewView { state.input.clear(); state.renaming = false; } else { - if self.exit_on_cancel { - self.app_event_tx - .send(AppEvent::Exit(crate::app::ExitMode::Immediate)); - } - state.completion = Some(ViewCompletion::Cancelled); + state.focus_composer(); } } ListAction::PageUp | ListAction::PageDown => { diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 01338a16a5..2d8ef54a58 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -415,6 +415,12 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ) .await?; + assert!(app.chat_widget.has_active_view()); + // Esc returns to the overview composer. Dismiss the retained view explicitly + // to inspect the unavailable conversation and its cached draft below. + app.agents_overview.view_state.lock().unwrap().completion = + Some(crate::bottom_pane::ViewCompletion::Accepted); + app.chat_widget.handle_key_event(KeyCode::Null.into()); assert!(!app.chat_widget.has_active_view()); assert_eq!(app.current_displayed_thread_id(), Some(id)); let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;