From a7913390f7bbd819d4b80fa2f6a67c15fce6cf53 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 31 Aug 2026 19:58:56 +0000 Subject: [PATCH] Preserve TUI drafts after app-server disconnects (#41911) ## Why Losing an external app-server connection can leave a submission's outcome uncertain. The TUI must preserve local work without retrying it or allowing stale server events to trigger more actions. ## What changed - Enter an offline state when an external app-server transport disconnects during startup, event streaming, or submission. - Keep conversation drafts, queued input, expanded pastes, attachments, and agent-overview input editable while blocking submissions, remote actions, and automatic queue replay. - Show a connection-lost notice that directs users to copy their work and relaunch, while retaining `Ctrl-C` and an empty-composer `Ctrl-D` as quit shortcuts. - Cancel pending views and asynchronous work, and ignore stale completions after the disconnect. Embedded app-server behavior is unchanged. ## Testing Added unit, snapshot, and pseudo-terminal coverage for lost startup and mutation replies, offline draft editing, queued-input recovery, stale-event quarantine, agent-overview behavior, and end-to-end preservation without reconnection or resubmission. GitOrigin-RevId: 7219a95fad676dda447d625db9e465345b469d58 --- codex-rs/tui/src/app.rs | 34 ++ codex-rs/tui/src/app/agents_overview.rs | 2 +- codex-rs/tui/src/app/agents_overview_view.rs | 41 ++- codex-rs/tui/src/app/app_server_events.rs | 3 + codex-rs/tui/src/app/event_dispatch.rs | 17 + codex-rs/tui/src/app/recap.rs | 2 +- codex-rs/tui/src/app/reconnect.rs | 66 ++++ codex-rs/tui/src/app/session_lifecycle.rs | 3 +- codex-rs/tui/src/app/startup.rs | 31 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 + .../tui/src/app/tests/disconnect_tests.rs | 346 ++++++++++++++++++ .../src/app/tests/recap_generation_tests.rs | 23 ++ ...s__disconnect__offline_command_center.snap | 28 ++ ...app__tests__disconnect__offline_draft.snap | 14 + ...s__disconnect__offline_expanded_paste.snap | 20 + codex-rs/tui/src/app/tests/startup.rs | 12 +- codex-rs/tui/src/app_event.rs | 2 +- codex-rs/tui/src/app_server_session.rs | 3 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 + .../bottom_pane/chat_composer/reconnect.rs | 37 ++ .../chat_composer/reconnect_tests.rs | 60 +++ codex-rs/tui/src/bottom_pane/mod.rs | 15 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/input_flow.rs | 1 + codex-rs/tui/src/chatwidget/input_queue.rs | 2 + codex-rs/tui/src/chatwidget/input_restore.rs | 8 + codex-rs/tui/src/chatwidget/interaction.rs | 4 + codex-rs/tui/src/chatwidget/reconnect.rs | 44 +++ .../chatwidget/tests/composer_submission.rs | 27 ++ .../tui/src/chatwidget/tests/review_mode.rs | 1 + codex-rs/tui/src/chatwidget/user_messages.rs | 1 + codex-rs/tui/tests/suite/focus_palette.rs | 24 +- codex-rs/tui/tests/suite/mod.rs | 2 + codex-rs/tui/tests/suite/reconnect.rs | 141 +++++++ 35 files changed, 987 insertions(+), 38 deletions(-) create mode 100644 codex-rs/tui/src/app/reconnect.rs create mode 100644 codex-rs/tui/src/app/tests/disconnect_tests.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/reconnect.rs create mode 100644 codex-rs/tui/tests/suite/reconnect.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f064fd018e..e2e909f71e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -229,6 +229,7 @@ mod platform_actions; mod plugin_mentions; mod rate_limit_refresh; mod recap; +mod reconnect; mod replay_filter; mod resize_reflow; mod safety_buffering; @@ -590,6 +591,7 @@ pub(crate) struct App { feedback_audience: FeedbackAudience, environment_manager: Arc, app_server_target: AppServerTarget, + reconnect: reconnect::ReconnectState, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -787,6 +789,18 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + if self.reconnect.offline + && let TuiEvent::Key(key) = &event + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && key.modifiers.contains(KeyModifiers::CONTROL) + && let KeyCode::Char(character) = key.code + && (character.eq_ignore_ascii_case(&'c') + || (character.eq_ignore_ascii_case(&'d') + && self.chat_widget.composer_is_empty() + && self.chat_widget.no_modal_or_popup_active())) + { + return Ok(AppRunControl::Exit(ExitReason::UserRequested)); + } let screen_size = tui.screen_size_for_event(&event)?; if !matches!( &event, @@ -805,6 +819,17 @@ impl App { event }; + if self.reconnect.offline + && let TuiEvent::Key(key) = &event + { + if self.reconnect.presentation == reconnect::ReconnectPresentation::Overview { + self.chat_widget.handle_disconnected_view_key(*key); + } else { + self.chat_widget.handle_disconnected_key(*key); + } + return Ok(AppRunControl::Continue); + } + match &event { TuiEvent::FocusLost => { let now = Instant::now(); @@ -840,6 +865,15 @@ impl App { // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); self.chat_widget.handle_paste(pasted); + if self.reconnect.offline + && self.reconnect.presentation + == reconnect::ReconnectPresentation::Conversation + { + self.chat_widget.handle_disconnected_key(KeyEvent::new( + KeyCode::Null, + KeyModifiers::NONE, + )); + } } TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => { if self.backtrack_render_pending { diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index e30e8574c3..b43a2cd00c 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -257,7 +257,7 @@ impl App { } } - fn agents_overview_view( + pub(super) fn agents_overview_view( &self, mut threads: Vec, selected_thread_id: Option, diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 17fe648d20..b0e4742c48 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -85,6 +85,7 @@ pub(super) struct AgentsOverviewRow { #[derive(Clone, Default)] pub(super) struct AgentsOverviewViewState { pub(super) input: String, + pub(super) connection_notice: Option<&'static str>, search: String, searching: bool, pub(super) status_grouping: bool, @@ -461,12 +462,12 @@ impl BottomPaneView for AgentsOverviewView { return; } - if self.agents_keymap.toggle_grouping.is_pressed(key) { - let mut state = self.state(); - state.status_grouping = !state.status_grouping; - return; - } - if self.agents_keymap.search.is_pressed(key) { + if self.agents_keymap.search.is_pressed(key) || { + let state = self.state(); + state.connection_notice.is_some() + && state.searching + && self.keymap.action_for(key) == Some(ListAction::Cancel) + } { let mut state = self.state(); if !state.renaming { state.searching = !state.searching; @@ -476,6 +477,21 @@ impl BottomPaneView for AgentsOverviewView { } return; } + + if self.state().connection_notice.is_some() { + match self.keymap.action_for(key) { + Some(ListAction::MoveUp) => self.move_selection(/*forward*/ false), + Some(ListAction::MoveDown) => self.move_selection(/*forward*/ true), + _ => {} + } + return; + } + + if self.agents_keymap.toggle_grouping.is_pressed(key) { + let mut state = self.state(); + state.status_grouping = !state.status_grouping; + return; + } if self.agents_keymap.new_task.is_pressed(key) { let mut state = self.state(); state.search.clear(); @@ -603,8 +619,12 @@ impl Renderable for AgentsOverviewView { } }); let attention = format!("{needs_you} need input"); - Line::from(format!("{attention} {working} working {ready} ready").dim()) - .render(inset(summary), buf); + if let Some(notice) = self.state().connection_notice { + Line::from(notice.cyan()).render(inset(summary), buf); + } else { + Line::from(format!("{attention} {working} working {ready} ready").dim()) + .render(inset(summary), buf); + } Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim()) .render(inset(divider), buf); let body = inset(body); @@ -654,6 +674,11 @@ 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) diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index aa5d89e2bc..f5e398a6da 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -78,6 +78,9 @@ impl App { .await; } AppServerEvent::Disconnected { message } => { + if self.begin_reconnect() { + return; + } tracing::warn!("app-server event stream disconnected: {message}"); self.chat_widget.add_error_message(message.clone()); self.app_event_tx.send(AppEvent::FatalExitRequest(message)); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 055612ef0b..693892539f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -30,6 +30,19 @@ impl App { app_server: &mut AppServerSession, event: AppEvent, ) -> Result { + if self.reconnect.offline + && !matches!( + &event, + AppEvent::InsertHistoryCell(_) + | AppEvent::AppendMessageHistoryEntry { .. } + | AppEvent::BeginInitialHistoryReplayBuffer + | AppEvent::BeginThreadSwitchHistoryReplayBuffer + | AppEvent::EndInitialHistoryReplayBuffer + | AppEvent::FatalExitRequest(_) + ) + { + return Ok(AppRunControl::Continue); + } if self.chat_widget.has_misalignment_policy_violation() && matches!( event, @@ -722,6 +735,10 @@ impl App { } self.chat_widget.prepare_local_op_submission(&op); if let Err(err) = self.submit_active_thread_op(app_server, op).await { + if self.recover_transport_error(&err) + { + return Ok(AppRunControl::Continue); + } let unsupported_permissions = err .downcast_ref::() .is_some(); diff --git a/codex-rs/tui/src/app/recap.rs b/codex-rs/tui/src/app/recap.rs index 6cfc75f25e..9841b4b684 100644 --- a/codex-rs/tui/src/app/recap.rs +++ b/codex-rs/tui/src/app/recap.rs @@ -176,7 +176,7 @@ pub(super) struct RecapRequest { } impl App { - fn clear_recap_request(&mut self, trigger: RecapTrigger) { + pub(super) fn clear_recap_request(&mut self, trigger: RecapTrigger) { self.recap.clear_in_flight_request(); if !matches!(trigger, RecapTrigger::Manual) { diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs new file mode 100644 index 0000000000..1054de0a02 --- /dev/null +++ b/codex-rs/tui/src/app/reconnect.rs @@ -0,0 +1,66 @@ +//! Preserve local work after an external connection is lost. Offline input and stale +//! async completions are quarantined; recovery requires relaunching the TUI. + +use super::*; + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(super) enum ReconnectPresentation { + #[default] + Conversation, + Overview, +} + +#[derive(Default)] +pub(super) struct ReconnectState { + pub(super) offline: bool, + pub(super) presentation: ReconnectPresentation, +} + +impl App { + pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool { + let disconnected = matches!( + error.downcast_ref::(), + Some(TypedRequestError::Transport { .. }) + ); + disconnected && self.begin_reconnect() + } + + pub(super) fn begin_reconnect(&mut self) -> bool { + if matches!(self.app_server_target, AppServerTarget::Embedded) { + return false; + } + if !self.reconnect.offline { + self.reconnect.offline = true; + self.cancel_pending_key_chord(); + self.overlay = None; + self.commit_animation = None; + self.clear_recap_request(crate::app_event::RecapTrigger::Manual); + self.reconnect.presentation = if self + .chat_widget + .selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) + .is_some() + { + if let Ok(mut state) = self.agents_overview.view_state.lock() { + state.connection_notice = Some("Disconnected — agent list is stale"); + if let Some(task) = state.refresh_task.take() { + task.abort(); + } + } + self.agents_overview.request_id = None; + self.agents_overview.refresh_pending = false; + ReconnectPresentation::Overview + } else { + self.chat_widget + .handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); + ReconnectPresentation::Conversation + }; + self.chat_widget.pause_for_disconnect(); + self.startup_pending_protected_request = false; + self.abort_all_thread_event_listeners(); + for (_, (_, task)) in self.dynamic_tool_tasks.drain() { + task.abort(); + } + } + true + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 9cbeee0883..96001c7f00 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -639,7 +639,7 @@ impl App { pub(super) async fn handle_startup_thread_started( &mut self, app_server: &mut AppServerSession, - result: Result, + result: Result, ) -> Result<()> { if !self.pending_startup_thread_start { if let Ok(started) = result { @@ -718,6 +718,7 @@ impl App { } self.chat_widget.maybe_send_next_queued_input(); } + Err(err) if self.recover_transport_error(&err) => {} Err(err) => { return Err(color_eyre::eyre::eyre!( "Failed to start a fresh session through the app server: {err}" diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 6756b20294..1858c1e5fb 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -37,8 +37,7 @@ fn spawn_startup_thread_start( remote_cwd_override, thread_tool_transport, ) - .await - .map_err(|err| format!("{err:#}")); + .await; app_event_tx.send(AppEvent::StartupThreadStarted { result }); }); } @@ -514,6 +513,7 @@ See the Codex keymap documentation for supported actions and examples." feedback_audience, environment_manager, app_server_target, + reconnect: Default::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -717,11 +717,15 @@ See the Codex keymap documentation for supported actions and examples." let initial_session_header_pending = waiting_for_initial_session_header && app.primary_session_configured.is_some() && has_pending_app_events; - let block_terminal_input_for_pending_startup_events = initial_session_header_pending - || (pending_startup_draft.is_some() || app.startup_protected_input_boundary) - && has_pending_app_events - || (!waiting_for_initial_session_configured - && app.has_queued_startup_protected_request()); + let block_terminal_input_for_pending_startup_events = + (!matches!(app.app_server_target, AppServerTarget::Embedded) + && has_pending_app_events) + || initial_session_header_pending + || (pending_startup_draft.is_some() + || app.startup_protected_input_boundary) + && has_pending_app_events + || (!waiting_for_initial_session_configured + && app.has_queued_startup_protected_request()); let control = select! { Some(event) = app_event_rx.recv() => { let is_initial_session_header = matches!( @@ -744,6 +748,7 @@ See the Codex keymap documentation for supported actions and examples." AppRunControl::Continue } Ok(AppRunControl::Exit(reason)) => AppRunControl::Exit(reason), + Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue, Err(err) => break Err(err), } } @@ -756,7 +761,7 @@ See the Codex keymap documentation for supported actions and examples." }, if App::should_handle_active_thread_events( waiting_for_initial_session_configured, app.active_thread_rx.is_some() - ) && !has_pending_app_events => { + ) && !has_pending_app_events && !app.reconnect.offline => { if let Some(event) = active { if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await { break Err(err); @@ -766,13 +771,14 @@ See the Codex keymap documentation for supported actions and examples." } AppRunControl::Continue } - event = tui_events.next(), if !block_terminal_input_for_pending_startup_events => { + event = tui_events.next(), if app.reconnect.offline || !block_terminal_input_for_pending_startup_events => { if let Some(event) = event { if (matches!( &event, TuiEvent::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) ) || matches!(&event, TuiEvent::Paste(_))) + && !app.reconnect.offline && pending_startup_draft.is_none() && !waiting_for_initial_session_configured && app_event_rx.is_empty() @@ -789,18 +795,21 @@ See the Codex keymap documentation for supported actions and examples." } match app.handle_tui_event(tui, &mut app_server, event).await { Ok(control) => control, - Err(err) => break Err(err), + Err(err) if app.recover_transport_error(&err) => AppRunControl::Continue, + Err(err) => break Err(err), } } else { tracing::warn!("terminal input stream closed; shutting down active thread"); app.handle_exit_mode(&mut app_server, ExitMode::ShutdownFirst).await } } - app_server_event = app_server.next_event(), if listen_for_app_server_events => { + app_server_event = app_server.next_event(), if listen_for_app_server_events && !app.reconnect.offline + && (matches!(app.app_server_target, AppServerTarget::Embedded) || !has_pending_app_events) => { match app_server_event { Some(event) => app.handle_app_server_event(&app_server, event).await, None => { listen_for_app_server_events = false; + app.begin_reconnect(); tracing::warn!("app-server event stream closed"); } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index c8a891e1a6..9ef42b70fc 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -56,6 +56,7 @@ pub(super) async fn make_test_app() -> App { feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + reconnect: Default::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index adeca53331..51b70a98bd 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -12,6 +12,8 @@ mod backend_banner_startup_tests; mod background_exit_tests; #[path = "tests/connector_policy.rs"] mod connector_policy; +#[path = "tests/disconnect_tests.rs"] +mod disconnect; #[path = "tests/key_chords.rs"] mod key_chords; #[path = "tests/mcp_startup.rs"] @@ -5453,6 +5455,7 @@ async fn make_test_app() -> App { feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + reconnect: Default::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -5535,6 +5538,7 @@ async fn make_test_app_with_channels() -> ( feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + reconnect: Default::default(), pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs new file mode 100644 index 0000000000..e30acddd92 --- /dev/null +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -0,0 +1,346 @@ +//! Offline editing, uncertain submissions, and stale events never consume local work. + +use super::*; +use crate::app_server_session::ThreadParamsMode; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCRequest; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn lost_mutation_reply_preserves_work_without_resubmitting() -> Result<()> { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let id = ThreadId::new(); + app.active_thread_id = Some(id); + app.primary_thread_id = Some(id); + app.chat_widget + .handle_thread_session(test_thread_session(id, app.config.cwd.to_path_buf())); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { + endpoint: endpoint.clone(), + }; + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + assert_eq!(request.method, "turn/start"); + std::future::ready(None) + }) + .await + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget + .restore_user_message_to_composer("only once".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = next_user_turn_op(&mut ops); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + app.chat_widget + .restore_user_message_to_composer("queued before disconnect".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.chat_widget + .restore_user_message_to_composer("keep this draft".into()); + let overview = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(overview)); + app.chat_widget.show_selection_view(SelectionViewParams { + items: vec![SelectionItem { + name: "Stale server action".into(), + actions: vec![Box::new(|tx| tx.send(AppEvent::CodexOp(Op::Interrupt)))], + ..Default::default() + }], + ..Default::default() + }); + assert!(matches!( + app.handle_event(&mut tui, &mut session, AppEvent::CodexOp(op)) + .await?, + AppRunControl::Continue + )); + assert!(app.reconnect.offline); + assert!(!app.chat_widget.has_active_view()); + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('D'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE), + ] { + assert!(matches!( + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?, + AppRunControl::Continue + )); + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "keep this draft!" + ); + assert!(app.chat_widget.has_queued_follow_up_messages()); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + assert!(!app.chat_widget.maybe_send_next_queued_input()); + app.handle_event(&mut tui, &mut session, AppEvent::NewSession { name: None }) + .await?; + assert_eq!(app.current_displayed_thread_id(), Some(id)); + while let Ok(event) = events.try_recv() { + app.handle_event(&mut tui, &mut session, event).await?; + } + let history = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { + loop { + if let Ok(text) = + tokio::fs::read_to_string(app.config.codex_home.join("history.jsonl")).await + && let Ok(entry) = + serde_json::from_str::(&text) + { + break entry; + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + }) + .await?; + assert_eq!( + (history.session_id, history.text), + (id.to_string(), "only once".into()) + ); + assert_snapshot!( + "offline_draft", + format!( + "{}\n{}", + lines_to_single_string( + &app.transcript_cells + .last() + .unwrap() + .display_lines(/*width*/ 80) + ), + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + ); + let mut keymap = codex_config::types::TuiKeymap::default(); + keymap.chat.edit_queued_message = Some(codex_config::types::KeybindingsSpec::One( + codex_config::types::KeybindingSpec("ctrl-x up".into()), + )); + let runtime = + RuntimeKeymap::from_config(&keymap).map_err(|error| color_eyre::eyre::eyre!(error))?; + app.chat_widget.apply_keymap_update(keymap, &runtime); + app.keymap = runtime; + for key in [ + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "queued before disconnect" + ); + assert!(!app.chat_widget.has_queued_follow_up_messages()); + let paste = "copied text ".repeat(/*n*/ 92); + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste(paste.clone())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + format!("queued before disconnect{paste}") + ); + let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80); + let draft = rendered + .split_once("› ") + .unwrap() + .1 + .split("\n\n") + .next() + .unwrap(); + assert_snapshot!("offline_expanded_paste", draft); + assert!(ops.try_recv().is_err()); + for character in ['c', 'C', 'd', 'D'] { + if character.eq_ignore_ascii_case(&'d') { + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)), + ) + .await?; + } + assert!(matches!( + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::CONTROL + )) + ) + .await?, + AppRunControl::Exit(ExitReason::UserRequested) + )); + } + session.shutdown().await?; + assert_eq!(server.await??, vec!["initialize", "turn/start"]); + Ok(()) +} + +#[tokio::test] +async fn disconnected_command_center_keeps_input_and_blocks_actions() -> Result<()> { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let mut session = crate::start_embedded_app_server_for_picker(&app.config).await?; + assert!(!app.begin_reconnect(), "embedded behavior stays unchanged"); + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:9")?, + }; + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + app.agents_overview.view_state.lock().unwrap().input = "task draft".into(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)), + ) + .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Paste("search query".into()), + ) + .await?; + assert!(app.begin_reconnect()); + for key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), + ] { + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key)) + .await?; + } + app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into())) + .await?; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.agents_overview.view_state.lock().unwrap().input, + "task draft!" + ); + assert!( + !std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. })) + ); + assert_snapshot!( + "offline_command_center", + render_bottom_popup(&app.chat_widget, /*width*/ 100) + ); + session.shutdown().await?; + Ok(()) +} + +// Common framing/bootstrap for both transports. Returning None deliberately loses a reply. +pub(super) async fn serve_reconnect_requests( + mut socket: tokio_tungstenite::WebSocketStream, + mut respond: impl FnMut(JSONRPCRequest) -> F, +) -> Result> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + F: std::future::Future>, +{ + let mut methods = Vec::new(); + while let Some(Ok(frame)) = socket.next().await { + let Message::Text(text) = frame else { continue }; + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + methods.push(request.method.clone()); + let request_id = request.id.clone(); + let response = match request.method.as_str() { + "initialize" => Some(json!({"result": {"userAgent": "reconnect-test"}})), + "account/read" => { + Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}})) + } + "model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), + "configRequirements/read" => Some(json!({"result": {"requirements": null}})), + _ => respond(request).await, + }; + let Some(mut response) = response else { + socket.close(None).await?; + break; + }; + response["id"] = json!(request_id); + socket + .send(Message::Text(response.to_string().into())) + .await?; + } + Ok(methods) +} +#[tokio::test] +async fn lost_initial_thread_reply_keeps_startup_draft_offline() -> Result<()> { + for disconnect_first in [false, true] { + let (mut app, _events, mut ops) = make_test_app_with_channels().await; + app.pending_startup_thread_start = true; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; + app.app_server_target = AppServerTarget::Remote { + endpoint: endpoint.clone(), + }; + app.chat_widget + .restore_user_message_to_composer("startup draft".into()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await?; + serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| { + assert_eq!(request.method, "thread/start"); + std::future::ready(None) + }) + .await + }); + let mut session = AppServerSession::new( + crate::connect_remote_app_server(endpoint).await?, + ThreadParamsMode::Remote, + ); + let result = crate::app_server_session::start_thread_with_request_handle( + session.request_handle(), + app.config.clone(), + ThreadParamsMode::Remote, + /*remote_cwd_override*/ None, + session.thread_tool_transport(), + ) + .await; + let mut tui = crate::tui::test_support::make_test_tui()?; + if disconnect_first { + app.begin_reconnect(); + } + app.handle_event( + &mut tui, + &mut session, + AppEvent::StartupThreadStarted { result }, + ) + .await?; + assert!(app.reconnect.offline); + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "startup draft" + ); + assert!( + !std::iter::from_fn(|| ops.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. })) + ); + session.shutdown().await?; + assert_eq!(server.await??, vec!["initialize", "thread/start"]); + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs index 98d10c6dfa..68eb334ae3 100644 --- a/codex-rs/tui/src/app/tests/recap_generation_tests.rs +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -267,6 +267,29 @@ async fn manual_recap_bypasses_automatic_eligibility_and_reports_failure() -> Re "rendered error: {rendered:?}" ); + // A completion quarantined during reconnect must not leave the next recap stuck as busy. + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + app.request_recap(&app_server, thread_id, RecapTrigger::Manual); + let stale = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("recap completion before disconnect"); + assert!(matches!(stale, AppEvent::RecapStarted { .. })); + assert!(app.begin_reconnect()); + app.handle_event(&mut tui, &mut app_server, stale).await?; + while app_event_rx.try_recv().is_ok() {} + app.reconnect.offline = false; + app.request_recap(&app_server, thread_id, RecapTrigger::Manual); + let next = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()).await?; + assert!( + matches!(next, Some(AppEvent::RecapStarted { .. })), + "{next:?}" + ); + app_server.shutdown().await?; proxy.await??; Ok(()) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap new file mode 100644 index 0000000000..17214d7a48 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_command_center.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: "render_bottom_popup(&app.chat_widget, 100)" +--- + Agent command center + Disconnected — agent list is stale + ──────────────────────────────────────────────────────────────────────────────────────────────── + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + New task › task draft! + ctrl+c quit · actions paused until the list is refreshed diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap new file mode 100644 index 0000000000..8e61fa4832 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_draft.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: "format!(\"{}\\n{}\",\nlines_to_single_string(&app.transcript_cells.last().unwrap().display_lines(80)),\nrender_bottom_popup(&app.chat_widget, 80))" +--- +■ Connection lost. +• Connection lost — copy your draft, then relaunch (0s) + +• Queued follow-up inputs + ↳ queued before disconnect + ⌥ + ↑ edit last queued message + +› keep this draft! + + ctrl+c quit diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap new file mode 100644 index 0000000000..9dcda88741 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__disconnect__offline_expanded_paste.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/app/tests/disconnect_tests.rs +expression: draft +--- +queued before disconnectcopied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text copied text copied text + copied text copied text copied text copied text diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index f49eaad61d..2912ba1e63 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -465,6 +465,16 @@ async fn startup_draft_delayed_approval_becomes_protected_on_redraw() -> Result< let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://127.0.0.1:1".into(), + auth_token: None, + }, + }; + app.begin_reconnect(); + assert!(app.startup_protected_input_boundary); + // The replacement connection replays the protected request after the old request was dropped. + app.reconnect.offline = false; let (mut startup_pane, _startup_app_event_rx) = startup_bottom_pane(); startup_pane.set_composer_text("draft".to_string(), Vec::new(), Vec::new()); @@ -1232,7 +1242,7 @@ async fn startup_thread_start_failure_returns_error() { .await .expect("embedded app server"); let err = app - .handle_startup_thread_started(&mut app_server, Err("boom".to_string())) + .handle_startup_thread_started(&mut app_server, Err(color_eyre::eyre::eyre!("boom"))) .await .expect_err("startup thread failure should exit instead of leaving chat unconfigured"); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index bb5b57aa3d..0e10c5cdac 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -373,7 +373,7 @@ pub(crate) enum AppEvent { /// Result of the fresh startup thread that is attached after the input UI is live. StartupThreadStarted { - result: Result, + result: color_eyre::Result, }, /// Register a dynamically created background thread before its first turn starts. diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4bd7a56ef9..231d4a4916 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -176,7 +176,8 @@ pub(crate) enum ThreadHistorySupport { } fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { - color_eyre::eyre::eyre!("{context}: {err}") + let message = format!("{context}: {err}"); + color_eyre::Report::new(err).wrap_err(message) } pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 189518676e..ebecf189e4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -107,6 +107,10 @@ //! navigation slash commands remain available so users can leave or manage the view. Transcript //! exports also remain available, including an explicit destination filename. //! +//! During reconnection, `handle_disconnected_key` edits the draft directly without +//! popup dispatch or submission. Enter and Tab leave the draft intact until reconnection succeeds. +//! Collapsed pastes expand into editable text so the full draft can be copied before quitting. +//! //! # Reasoning Effort Animations //! //! The composer observes the effective reasoning tier whenever model-dependent surfaces refresh. @@ -292,6 +296,7 @@ mod draft_state; mod footer_state; mod history_search; mod popup_state; +mod reconnect; mod slash_input; mod vim_search; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs new file mode 100644 index 0000000000..0c65ebeebc --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -0,0 +1,37 @@ +//! Offline editing bypasses command dispatch and popup actions, retaining the draft in place. + +use super::*; + +impl ChatComposer { + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + self.cancel_history_search(); + self.attachments.clear_remote_image_selection(); + self.popups.active = ActivePopup::None; + self.set_disable_paste_burst(/*disabled*/ true); + // Expand in reverse order so remaining ranges stay valid. Textarea replacement + // preserves the cursor and other elements, including images and mentions. + let pending_pastes = std::mem::take(&mut self.draft.pending_pastes); + if !pending_pastes.is_empty() { + for element in self.draft.textarea.text_elements().into_iter().rev() { + if let Some((_, text)) = pending_pastes.iter().find(|(placeholder, _)| { + element.placeholder(self.draft.textarea.text()) == Some(placeholder.as_str()) + }) { + self.draft + .textarea + .replace_range(element.byte_range.start..element.byte_range.end, text); + } + } + } + // Enter/Tab and configured submit bindings must never consume the draft offline. + // The basic editor reconciles attachments without invoking composer-level shortcuts. + if !matches!(key.code, KeyCode::Enter | KeyCode::Tab) + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + self.handle_input_basic(key); + } + } +} + +#[cfg(test)] +#[path = "reconnect_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs new file mode 100644 index 0000000000..36fb5a4e70 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/reconnect_tests.rs @@ -0,0 +1,60 @@ +//! Offline edits retain the same attachment bookkeeping as ordinary text edits. + +use super::super::tests::new_test_composer; +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn reconnect_expands_pastes_preserving_images_and_cursor() { + let (mut composer, _) = new_test_composer(); + let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + composer.handle_paste(paste.clone()); + composer.attach_image(PathBuf::from("local.png")); + composer.handle_paste(paste); + let expanded = composer.current_text_with_pending(); + let images = composer.draft_snapshot().local_images; + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + let draft = composer.draft_snapshot(); + assert_eq!( + ( + draft.text, + draft.cursor, + draft.local_images, + draft.pending_pastes + ), + ( + format!("{expanded}!"), + expanded.len() + 1, + images, + Vec::new() + ) + ); + assert_eq!(draft.text_elements.len(), 1); +} + +#[test] +fn reconnect_edit_removes_deleted_attachment_and_paste_metadata() { + let (mut composer, _) = new_test_composer(); + composer.attach_image(PathBuf::from("local.png")); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + let draft = composer.draft_snapshot(); + assert_eq!( + (draft.text, draft.local_images, draft.pending_pastes), + (String::new(), Vec::new(), Vec::new()) + ); +} + +#[test] +fn reconnect_edit_cancels_history_preview_without_losing_original_draft() { + let (mut composer, _) = new_test_composer(); + composer.set_text_content("original draft".into(), Vec::new(), Vec::new()); + composer.draft.textarea.set_cursor(/*pos*/ 14); + composer.begin_history_search(); + composer.apply_history_search_result(HistorySearchResult::Found(HistoryEntry::new( + "history preview".into(), + ))); + composer.handle_disconnected_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE)); + assert_eq!(composer.current_text(), "original draft!"); + assert!(!composer.history_search_active()); +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fd2fd4a104..75d4ff466e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -650,6 +650,16 @@ impl BottomPane { self.push_view(Box::new(modal)); } + /// Edit the draft without invoking popups, submissions, or remote actions. + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + self.view_stack.clear(); + self.delayed_approval_requests.clear(); + self.composer + .set_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.composer.handle_disconnected_key(key); + self.request_redraw(); + } + /// Forward a key event to the active view or the composer. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult { // If a modal/view is active, handle it here; otherwise forward to composer. @@ -2374,6 +2384,11 @@ mod tests { assert_eq!(pane.composer_text(), "ya"); assert!(pane.view_stack.is_empty()); assert_eq!(pane.delayed_approval_requests.len(), 1); + pane.handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); + pane.pre_draw_tick_at(Instant::now() + APPROVAL_PROMPT_TYPING_IDLE_DELAY); + pane.handle_paste(" kept".into()); + assert_eq!(pane.composer_text(), "ya kept"); + assert!(pane.view_stack.is_empty()); while let Ok(event) = rx.try_recv() { assert!( !matches!(event, AppEvent::SubmitThreadOp { .. }), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 913a61069a..01a1dd9b14 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -354,6 +354,7 @@ mod goal_menu; mod ide_context; use self::ide_context::IdeContextState; mod input_queue; +mod reconnect; use self::input_queue::InputQueueState; mod input_flow; mod input_restore; diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 2e056558fa..74d872d6c0 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -143,6 +143,7 @@ impl ChatWidget { || self.misalignment_policy_violation || self.input_queue.suppress_queue_autosend || self.input_queue.rate_limit_recovery_pending + || self.input_queue.recovered_queue { return false; } diff --git a/codex-rs/tui/src/chatwidget/input_queue.rs b/codex-rs/tui/src/chatwidget/input_queue.rs index f14059e3ca..249cade908 100644 --- a/codex-rs/tui/src/chatwidget/input_queue.rs +++ b/codex-rs/tui/src/chatwidget/input_queue.rs @@ -44,6 +44,7 @@ pub(super) struct InputQueueState { pub(super) suppress_queue_autosend: bool, /// Hold submissions while a usage failure or backend-directed model fallback is resolved. pub(super) rate_limit_recovery_pending: bool, + pub(super) recovered_queue: bool, } impl InputQueueState { @@ -52,6 +53,7 @@ impl InputQueueState { } pub(super) fn clear(&mut self) { + self.recovered_queue = false; self.queued_user_messages.clear(); self.queued_user_message_history_records.clear(); self.user_turn_pending_start = false; diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 89d93a6cb0..4d9655db55 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -179,6 +179,8 @@ impl ChatWidget { pub(super) fn pop_latest_queued_composer_state(&mut self) -> Option { if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { + self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages() + || !self.input_queue.pending_steers.is_empty(); let history_record = self .input_queue .queued_user_message_history_records @@ -195,6 +197,8 @@ impl ChatWidget { )) } else { let user_message = self.input_queue.rejected_steers_queue.pop_back()?; + self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages() + || !self.input_queue.pending_steers.is_empty(); let history_record = self .input_queue .rejected_steer_history_records @@ -467,6 +471,7 @@ impl ChatWidget { .input_queue .queued_user_message_history_records .clone(), + recovered_queue: self.input_queue.recovered_queue, user_turn_pending_start: self.input_queue.user_turn_pending_start, submit_pending_steers_after_interrupt: self .input_queue @@ -487,6 +492,7 @@ impl ChatWidget { let restored_task_running = preserve_in_flight_turn && input_state.as_ref().is_some_and(|state| state.task_running); if let Some(input_state) = input_state { + self.input_queue.recovered_queue = input_state.recovered_queue; self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; self.safety_buffering_prompt = input_state.safety_buffering_prompt; @@ -559,6 +565,8 @@ impl ChatWidget { self.input_queue.clear(); self.restore_composer_state(Default::default()); } + self.input_queue.recovered_queue &= self.input_queue.has_queued_follow_up_messages() + || !self.input_queue.pending_steers.is_empty(); let effort = self.effective_reasoning_effort(); self.bottom_pane .set_active_reasoning_effort_baseline(effort.as_ref()); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 24e5470063..ffa8c25306 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -245,6 +245,10 @@ impl ChatWidget { self.bottom_pane.selected_index_for_present_view(view_id) } + pub(crate) fn selected_index_for_active_view(&self, view_id: &'static str) -> Option { + self.bottom_pane.selected_index_for_active_view(view_id) + } + pub(crate) fn replace_selection_view_if_present( &mut self, view_id: &'static str, diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs new file mode 100644 index 0000000000..7c99c199eb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -0,0 +1,44 @@ +//! Disconnects preserve editable input but never automatically retry queued submissions. + +use super::*; + +impl ChatWidget { + pub(crate) fn pause_for_disconnect(&mut self) { + self.input_queue.recovered_queue = true; + self.input_queue.suppress_queue_autosend = true; + self.set_initial_user_message_submit_suppressed(/*suppressed*/ true); + if let Some(message) = self.initial_user_message.take() { + self.restore_user_message_to_composer(message); + } + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ false); + self.set_status_header("Connection lost — copy your draft, then relaunch".to_string()); + self.set_footer_hint_override(Some(vec![("ctrl+c".into(), "quit".into())])); + self.add_error_message("Connection lost.".into()); + } + + pub(crate) fn handle_disconnected_view_key(&mut self, key: KeyEvent) { + self.bottom_pane.handle_key_event(key); + } + + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + if key.kind == KeyEventKind::Press && self.chat_keymap.edit_queued_message.is_pressed(key) { + if let Some(composer) = self.pop_latest_queued_composer_state() { + self.restore_composer_state(composer); + } else if let Some(steer) = self.input_queue.pending_steers.pop_back() { + self.restore_user_message_to_composer(user_message_for_restore( + steer.user_message, + &steer.history_record, + )); + self.input_queue.recovered_queue &= !self.input_queue.pending_steers.is_empty(); + } + self.bottom_pane + .handle_disconnected_key(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)); + self.refresh_pending_input_preview(); + self.request_redraw(); + } else { + self.bottom_pane.handle_disconnected_key(key); + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 394fe45c33..2edcda24df 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -304,6 +304,17 @@ async fn parent_owned_thread_blocks_settings_shortcuts() { assert_chatwidget_snapshot!("parent_owned_thread_rejects_settings_shortcuts", rendered); } +#[tokio::test] +async fn disconnect_restores_initial_prompt_without_submitting_it() { + let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.initial_user_message = Some("CLI prompt".into()); + chat.restore_user_message_to_composer("typed draft".into()); + chat.pause_for_disconnect(); + chat.submit_initial_user_message_if_pending(); + assert_eq!(chat.composer_text_with_pending(), "CLI prompt\ntyped draft"); + assert_no_submit_op(&mut ops); +} + #[tokio::test] async fn parent_owned_thread_restores_pending_initial_prompt() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ Some("gpt-5")).await; @@ -1757,6 +1768,7 @@ async fn restore_thread_input_state_applies_running_state_policy() { rejected_steer_history_records: VecDeque::new(), queued_user_messages: VecDeque::from([UserMessage::from("already queued").into()]), queued_user_message_history_records: VecDeque::from([queued_history.clone()]), + recovered_queue: false, user_turn_pending_start: true, submit_pending_steers_after_interrupt: true, current_collaboration_mode: chat.current_collaboration_mode.clone(), @@ -1790,6 +1802,21 @@ async fn restore_thread_input_state_applies_running_state_policy() { Some(UserMessage::from("buffered prompt")) ); + chat.pause_for_disconnect(); + chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + assert!(!chat.has_queued_follow_up_messages()); + // Editing the last queued draft must not release the uncertain steer for replay. + assert!(chat.capture_thread_input_state().unwrap().recovered_queue); + chat.handle_disconnected_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + assert_eq!( + chat.composer_text_with_pending(), + "submitted history\nqueued history" + ); + assert!(chat.input_queue.pending_steers.is_empty()); + assert!(!chat.capture_thread_input_state().unwrap().recovered_queue); + assert_no_submit_op(&mut op_rx); + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.restore_thread_input_state( Some(input_state), ThreadInputStateRestoreMode { diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index ff3828a4c3..ed3853e919 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -384,6 +384,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ rejected_steer_history_records: VecDeque::new(), queued_user_messages, queued_user_message_history_records: VecDeque::new(), + recovered_queue: false, user_turn_pending_start: false, submit_pending_steers_after_interrupt: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index 5c7c18e594..50feb09b80 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -131,6 +131,7 @@ pub(crate) struct ThreadInputState { pub(super) rejected_steer_history_records: VecDeque, pub(super) queued_user_messages: VecDeque, pub(super) queued_user_message_history_records: VecDeque, + pub(crate) recovered_queue: bool, pub(super) user_turn_pending_start: bool, pub(super) submit_pending_steers_after_interrupt: bool, pub(super) current_collaboration_mode: CollaborationMode, diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index 5b96726590..71a4cb57ba 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -45,7 +45,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R Ok(()) } -struct PtyCodex { +pub(super) struct PtyCodex { master: File, child: Child, parser: vt100::Parser, @@ -57,7 +57,7 @@ struct PtyCodex { } impl PtyCodex { - fn start(repo_root: &Path, codex_home: TempDir) -> Result { + pub(super) fn start(repo_root: &Path, codex_home: TempDir) -> Result { let mut master_fd = -1; let mut slave_fd = -1; let mut window_size = libc::winsize { @@ -89,14 +89,12 @@ impl PtyCodex { let stdin = slave.try_clone().context("clone pseudo-terminal stdin")?; let stdout = slave.try_clone().context("clone pseudo-terminal stdout")?; - let codex = codex_utils_cargo_bin::cargo_bin("codex") - .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex-tui"))?; + let codex = codex_utils_cargo_bin::cargo_bin("codex-tui") + .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?; let child = Command::new(codex) .arg("--no-alt-screen") .arg("-C") .arg(repo_root) - .arg("-c") - .arg("analytics.enabled=false") .env("TERM", "xterm-256color") .env("OPENAI_API_KEY", "focus-palette-test") .env("CODEX_HOME", codex_home.path()) @@ -120,7 +118,7 @@ impl PtyCodex { }) } - fn wait_for_startup(&mut self) -> Result<()> { + pub(super) fn wait_for_startup(&mut self) -> Result<()> { let deadline = Instant::now() + STARTUP_TIMEOUT; while Instant::now() < deadline { self.read_output(Duration::from_millis(/*millis*/ 50))?; @@ -194,7 +192,7 @@ impl PtyCodex { Ok(()) } - fn read_output(&mut self, timeout: Duration) -> Result<()> { + pub(super) fn read_output(&mut self, timeout: Duration) -> Result<()> { let timeout_ms = timeout.as_millis().min(libc::c_int::MAX as u128) as libc::c_int; let mut descriptor = libc::pollfd { fd: self.master.as_raw_fd(), @@ -224,17 +222,17 @@ impl PtyCodex { Ok(()) } - fn write_input(&mut self, bytes: &[u8]) -> Result<()> { + pub(super) fn write_input(&mut self, bytes: &[u8]) -> Result<()> { self.master.write_all(bytes)?; self.master.flush()?; Ok(()) } - fn screen_contains(&self, text: &str) -> bool { + pub(super) fn screen_contains(&self, text: &str) -> bool { self.parser.screen().contents().contains(text) } - fn screen_contents(&self) -> String { + pub(super) fn screen_contents(&self) -> String { self.parser.screen().contents() } } @@ -250,11 +248,11 @@ fn contains_bytes(buffer: &[u8], needle: &[u8]) -> bool { buffer.windows(needle.len()).any(|window| window == needle) } -fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<()> { +pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<()> { let repo_root = repo_root.display(); let config = format!( "model = \"gpt-5.6-terra\"\nmodel_provider = \"openai\"\n\ - suppress_unstable_features_warning = true\n\n\ + suppress_unstable_features_warning = true\nanalytics.enabled = false\n\n\ [projects.\"{repo_root}\"]\ntrust_level = \"trusted\"\n" ); std::fs::write(codex_home.join("config.toml"), config) diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs index 9565ec853f..9fd63ed726 100644 --- a/codex-rs/tui/tests/suite/mod.rs +++ b/codex-rs/tui/tests/suite/mod.rs @@ -1,6 +1,8 @@ // Aggregates all former standalone integration tests as modules. #[cfg(unix)] mod focus_palette; +#[cfg(unix)] +mod reconnect; mod resize_reflow; mod status_indicator; mod vt100_history; diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs new file mode 100644 index 0000000000..22e1b057c4 --- /dev/null +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -0,0 +1,141 @@ +//! Drives disconnect handling through the real binary and terminal event loop. + +use super::focus_palette::PtyCodex; +use super::focus_palette::write_test_config; +use anyhow::Result; +use anyhow::ensure; +use codex_app_server_protocol::JSONRPCMessage; +use futures::SinkExt; +use futures::StreamExt; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use std::time::Instant; +use tokio::net::UnixListener; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disconnect_preserves_editable_draft_in_terminal() -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + // macOS's default temporary directory leaves too little room for the control socket path. + let codex_home = tempfile::tempdir_in("/tmp")?; + write_test_config(codex_home.path(), &repo_root)?; + let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; + std::fs::create_dir_all(socket.parent().unwrap())?; + let listener = UnixListener::bind(socket.as_path())?; + let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel(); + let server_cwd = repo_root.clone(); + let server = tokio::spawn(async move { + let mut methods = Vec::new(); + let id = "00000000-0000-0000-0000-000000000001"; + let thread = json!({ + "id": id, "sessionId": id, "preview": "", "ephemeral": false, + "modelProvider": "openai", "createdAt": 1, "updatedAt": 2, + "status": {"type": "active", "activeFlags": []}, "cwd": server_cwd, + "cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}] + }); + for connection in 0..1 { + let mut socket = loop { + let (stream, _) = listener.accept().await?; + // Startup probes the default daemon socket before opening its WebSocket. + if let Ok(socket) = tokio_tungstenite::accept_async(stream).await { + break socket; + } + }; + loop { + let frame = tokio::select! { + _ = &mut disconnect_rx, if connection == 0 => { + socket.close(/*msg*/ None).await?; + break; + } + frame = socket.next() => frame, + }; + let Some(Ok(Message::Text(text))) = frame else { + break; + }; + let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else { + continue; + }; + methods.push(request.method.clone()); + let result = match request.method.as_str() { + "initialize" => json!({"userAgent": "reconnect-pty"}), + "account/read" => { + json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false}) + } + "model/list" => json!({"data": [], "nextCursor": null}), + "configRequirements/read" => json!({"requirements": null}), + "thread/start" | "thread/resume" => { + json!({"thread": thread, "model": "gpt-5.6-terra", "modelProvider": "openai", + "cwd": server_cwd, "approvalPolicy": "never", "approvalsReviewer": "user", + "sandbox": {"type": "readOnly"}, "reasoningEffort": null}) + } + "thread/read" => json!({"thread": thread}), + "thread/goal/get" => json!({"goal": null}), + "skills/list" => json!({"data": []}), + _ => { + socket + .send(Message::Text( + json!({"id": request.id, "error": { + "code": -32601, "message": "method not found" + }}) + .to_string() + .into(), + )) + .await?; + continue; + } + }; + socket + .send(Message::Text( + json!({"id": request.id, "result": result}) + .to_string() + .into(), + )) + .await?; + } + } + Ok::<_, anyhow::Error>(methods) + }); + let mut terminal = PtyCodex::start(&repo_root, codex_home)?; + terminal.wait_for_startup()?; + let mut disconnect_tx = Some(disconnect_tx); + for expected in [ + "gpt-5.6-terra", + "preserved-draft", + "Connection lost", + "preserved-draft!", + ] { + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30); + while !terminal.screen_contains(expected) && Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 20))?; + } + ensure!( + terminal.screen_contains(expected), + "missing {expected}; screen:\n{}", + terminal.screen_contents() + ); + match expected { + "gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?, + "preserved-draft" => { + disconnect_tx.take().unwrap().send(()).unwrap(); + } + "Connection lost" => terminal.write_input(b"!")?, + _ => {} + } + } + ensure!( + terminal.screen_contains("preserved-draft!"), + "draft was lost after recovery" + ); + drop(terminal); + let methods = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), server).await???; + assert_eq!( + methods + .iter() + .filter(|method| *method == "thread/resume") + .count(), + 0 + ); + assert!(!methods.iter().any(|method| method == "turn/start")); + Ok(()) +}