From 4aec23384e85734bbae3a3eed06a9218babc4e51 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 6 Sep 2026 17:13:07 +0000 Subject: [PATCH] Show read-only conversations when resume encounters an active writer (#43253) ## Why Resuming a conversation that is open in another app fails with an active-writer error. Allow users to inspect its transcript and retry after closing it in the other app. ## What changed - Fall back to a read-only history snapshot on active-writer conflicts during startup and session selection. - Replace the composer with an explanatory notice, `R` to retry, exit shortcuts, and the configured transcript shortcut. - Block input and freeze active command and MCP animations while viewing the snapshot. Preserve initial prompts and existing drafts for a successful retry. - Keep the subscription when retrying the same thread and preserve read-only state across thread switches and reconnects. ## Testing Add regression tests for conflict detection, history viewing, draft preservation, same-thread retry without unsubscribing, exit shortcuts, and notice and frozen-tool rendering. GitOrigin-RevId: fa864cc73143cad9139c22e33b7958569eec20cf --- codex-rs/tui/src/app/input.rs | 44 +++++ codex-rs/tui/src/app/reconnect.rs | 10 +- codex-rs/tui/src/app/session_lifecycle.rs | 167 ++++++++++++------ codex-rs/tui/src/app/startup.rs | 67 +++++-- codex-rs/tui/src/app/tests.rs | 16 +- .../src/app/tests/background_exit_tests.rs | 89 ++++++++++ .../app/tests/session_lifecycle_requests.rs | 57 ++++++ codex-rs/tui/src/app/tests/startup.rs | 68 +++++++ codex-rs/tui/src/app/thread_events.rs | 5 + codex-rs/tui/src/app/thread_routing.rs | 21 ++- codex-rs/tui/src/app_server_session.rs | 5 + .../src/app_server_session/rollout_history.rs | 49 +++++ .../rollout_history_tests.rs | 43 +++++ codex-rs/tui/src/bottom_pane/footer.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 5 + codex-rs/tui/src/chatwidget.rs | 27 ++- codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/input_flow.rs | 7 +- codex-rs/tui/src/chatwidget/interaction.rs | 5 +- codex-rs/tui/src/chatwidget/reconnect.rs | 3 + codex-rs/tui/src/chatwidget/rendering.rs | 128 +++++++++++++- .../tui/src/chatwidget/rendering_tests.rs | 54 ++++++ ...view_shows_notice_instead_of_composer.snap | 10 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 45 +++++ ...t_freezes_active_command_and_mcp_rows.snap | 8 + codex-rs/tui/src/exec_cell/model.rs | 4 + codex-rs/tui/src/history_cell/mcp.rs | 4 + codex-rs/tui/src/resume_picker.rs | 18 +- codex-rs/tui/src/style.rs | 16 ++ 29 files changed, 869 insertions(+), 109 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__exec_flow__external_writer_snapshot_freezes_active_command_and_mcp_rows.snap diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a6f4ab0e11..3d1a6794ef 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -233,6 +233,43 @@ impl App { app_server: &mut AppServerSession, key_event: KeyEvent, ) { + if self.chat_widget.is_external_writer_view() + && self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + && key_event.kind == KeyEventKind::Press + { + let modifiers = key_event.modifiers; + let quit = match key_event.code { + KeyCode::Esc => modifiers == KeyModifiers::NONE, + KeyCode::Char('q' | 'Q') => { + modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT + } + KeyCode::Char('c' | 'C') => { + modifiers == KeyModifiers::CONTROL + || modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT) + } + _ => false, + }; + if quit { + self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + return; + } + if matches!(key_event.code, KeyCode::Char('r' | 'R')) + && (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT) + && let Some(thread_id) = self.current_displayed_thread_id() + { + let target = SessionTarget { + path: self + .primary_session_configured + .as_ref() + .and_then(|session| session.rollout_path.clone()), + thread_id, + history_mode: None, + }; + let _ = self.resume_target_session(tui, app_server, target).await; + return; + } + } // Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless // enhanced keyboard reporting is available. We only treat those word-motion fallbacks as // agent-switch shortcuts when the composer is empty so we never steal the expected @@ -376,6 +413,13 @@ impl App { return; } + if self.chat_widget.is_external_writer_view() + && self.overlay.is_none() + && self.chat_widget.no_modal_or_popup_active() + { + return; + } + if self.should_handle_unavailable_thread_key(key_event) { self.chat_widget.handle_disconnected_key(key_event); return; diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 033eab9754..f457dbb01b 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -158,7 +158,7 @@ impl App { && self .thread_event_channels .get(&id) - .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ReplayOnly) + .is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live) } pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool { @@ -284,7 +284,11 @@ impl App { // so their late requests cannot leak into recovery. Background threads attach on selection. for channel in self.thread_event_channels.values_mut() { let mut replacement = ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY); - replacement.mark_replay_only(); + if channel.attachment() == ThreadEventAttachment::ExternalWriter { + replacement.mark_external_writer(); + } else { + replacement.mark_replay_only(); + } let mut store = std::mem::replace( &mut *channel.store.lock().await, ThreadEventStore::new(THREAD_EVENT_CHANNEL_CAPACITY), @@ -349,7 +353,7 @@ impl App { )?; self.config = self.chat_widget.config_ref().clone(); self.refresh_pending_thread_approvals().await; - if self.thread_unavailable(id) { + if self.thread_unavailable(id) && !self.chat_widget.is_external_writer_view() { self.agent_navigation.mark_stopped(id); self.chat_widget.pause_unavailable_thread(); self.chat_widget.add_info_message("This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.".into(), /*hint*/ None); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index a5b143e6b2..1994e2176a 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -13,6 +13,7 @@ use super::app_server_event_targets::server_request_thread_id; use super::*; use crate::app_server_session::source_agent_path; use crate::app_server_session::thread_blocks_direct_input; +use crate::chatwidget::ThreadInputStateRestoreMode; use std::collections::HashSet; #[derive(Clone, Copy)] @@ -366,6 +367,10 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result { + let was_external_writer = self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter); if self .thread_event_channels .get(&thread_id) @@ -459,7 +464,11 @@ impl App { }; let channel = self.ensure_thread_channel(thread_id); if !live_attached { - channel.mark_replay_only(); + if was_external_writer { + channel.mark_external_writer(); + } else { + channel.mark_replay_only(); + } } let mut store = channel.store.lock().await; store.set_session(session, turns); @@ -627,7 +636,12 @@ impl App { self.schedule_recap_check(thread_id, now); self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; - if is_replay_only { + if is_replay_only + && self + .thread_event_channels + .get(&thread_id) + .is_none_or(|channel| channel.attachment() != ThreadEventAttachment::ExternalWriter) + { self.chat_widget.pause_unavailable_thread(); let message = if attached_replay_only { format!( @@ -661,11 +675,21 @@ impl App { .set_task_mentions_enabled(app_server.task_tools_available(thread_id)); self.chat_widget .note_rendered_width(tui.terminal.last_known_screen_size.width); + let external_writer = self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter); + if external_writer { + self.chat_widget.show_external_writer_thread(); + } if self.agent_navigation.is_parent_owned(thread_id) { self.chat_widget.set_parent_owned_thread(); } self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, resume_restored_queue); + if external_writer { + self.chat_widget.show_external_writer_thread(); + } Ok(()) } @@ -1133,69 +1157,106 @@ impl App { if let Some(history_mode) = target_session.history_mode { app_server.remember_thread_history_mode(target_session.thread_id, history_mode); } - match app_server + let resumed = app_server .resume_thread( &local_settings, resume_config.clone(), target_session.thread_id, self.resume_model_settings(), ) - .await - { - Ok(resumed) => { - let resumed_thread_id = resumed.session.thread_id; - self.shutdown_current_thread(app_server).await; - self.local_settings = local_settings; - self.config = resume_config; - tui.set_notification_settings( - self.local_settings.tui.notification_settings.method, - self.local_settings.tui.notification_settings.condition, - ); - self.file_search - .update_search_dir(self.config.cwd.to_path_buf()); - match self - .replace_chat_widget_with_app_server_thread( - tui, - resumed, - ThreadAttachPresentation::SessionLineage, - /*initial_user_message*/ None, - ) - .await - { - Ok(()) => { - self.backfill_loaded_subagent_threads(app_server).await; - self.replay_agents_overview_requests(app_server, resumed_thread_id) - .await; - if let Some(summary) = summary { - let mut lines: Vec> = Vec::new(); - if let Some(usage_line) = summary.usage_line { - lines.push(usage_line.into()); - } - if let Some(command) = summary.resume_hint { - let spans = - vec!["To continue this session, run ".into(), command.cyan()]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); - } - self.maybe_prompt_resume_paused_goal_after_resume( - app_server, - resumed_thread_id, - ) - .await; - } - Err(err) => { - self.add_session_picker_error(format!( - "Failed to attach to resumed app-server thread: {err}" - )); - } + .await; + let (resumed, read_only) = match resumed { + Ok(resumed) => (resumed, false), + Err(err) if crate::app_server_session::is_active_writer_error(&err) => match app_server + .read_thread_for_viewing(&resume_config, &local_settings, target_session.thread_id) + .await + { + Ok(thread) => (thread, true), + Err(read_err) => { + self.add_session_picker_error(format!( + "Failed to view thread open elsewhere: {read_err}" + )); + return Ok(AppRunControl::Continue); } - } + }, Err(err) => { let path_display = target_session.display_label(); self.add_session_picker_error(format!( "Failed to resume session from {path_display}: {err}" )); + return Ok(AppRunControl::Continue); + } + }; + let resumed_thread_id = resumed.session.thread_id; + let retained_input = (self.chat_widget.is_external_writer_view() + && self.chat_widget.thread_id() == Some(resumed_thread_id)) + .then(|| self.chat_widget.capture_thread_input_state()) + .flatten(); + if self.chat_widget.thread_id() == Some(resumed_thread_id) { + // The successful resume has already subscribed this connection to the same thread. + self.shutdown_side_threads(app_server).await; + } else { + self.shutdown_current_thread(app_server).await; + } + self.local_settings = local_settings; + self.config = resume_config; + tui.set_notification_settings( + self.local_settings.tui.notification_settings.method, + self.local_settings.tui.notification_settings.condition, + ); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + match self + .replace_chat_widget_with_app_server_thread( + tui, + resumed, + ThreadAttachPresentation::SessionLineage, + /*initial_user_message*/ None, + ) + .await + { + Ok(()) => { + if let Some(input) = retained_input { + self.chat_widget.restore_thread_input_state( + Some(input), + ThreadInputStateRestoreMode { + preserve_in_flight_turn: false, + }, + ); + } + if read_only { + self.ensure_thread_channel(resumed_thread_id) + .mark_external_writer(); + self.chat_widget.show_external_writer_thread(); + } + self.backfill_loaded_subagent_threads(app_server).await; + if !read_only { + self.replay_agents_overview_requests(app_server, resumed_thread_id) + .await; + } + if let Some(summary) = summary { + let mut lines: Vec> = Vec::new(); + if let Some(usage_line) = summary.usage_line { + lines.push(usage_line.into()); + } + if let Some(command) = summary.resume_hint { + let spans = vec!["To continue this session, run ".into(), command.cyan()]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + if !read_only { + self.maybe_prompt_resume_paused_goal_after_resume( + app_server, + resumed_thread_id, + ) + .await; + } + } + Err(err) => { + self.add_session_picker_error(format!( + "Failed to attach to resumed app-server thread: {err}" + )); } } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index c014dc2b02..f0a0d34f81 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -363,6 +363,7 @@ impl App { ); let start_in_agents_overview = matches!(&session_selection, SessionSelection::AgentsOverview); + let mut read_only_thread = false; let (mut chat_widget, initial_started_thread) = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit @@ -450,24 +451,50 @@ impl App { ) .await { + Ok(Ok(resumed)) => Ok(resumed), + Ok(Err(err)) if crate::app_server_session::is_active_writer_error(&err) => { + read_only_thread = true; + match startup_draft + .run_until( + tui, + app_server.read_thread_for_viewing( + &config, + &local_settings, + target_session.thread_id, + ), + ) + .await + { + Ok(result) => result, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + } + } Ok(resumed) => resumed, Err(err) => return shutdown_on_startup_error(app_server, err).await, }; - let action = SessionStartAction::Resume(model_settings); - let Some(resumed) = complete_session_start( - &mut app_server, - &config, - &target_session, - action, - resumed, - async || { - startup_draft.flush_pending_events(tui).await?; - run_unarchive_prompt(tui, target_session.thread_id, action).await - }, - ) - .await? - else { - return Ok(cancel_session_start(app_server).await); + let resumed = if read_only_thread { + match resumed { + Ok(resumed) => resumed, + Err(err) => return shutdown_on_startup_error(app_server, err).await, + } + } else { + let action = SessionStartAction::Resume(model_settings); + let Some(resumed) = complete_session_start( + &mut app_server, + &config, + &target_session, + action, + resumed, + async || { + startup_draft.flush_pending_events(tui).await?; + run_unarchive_prompt(tui, target_session.thread_id, action).await + }, + ) + .await? + else { + return Ok(cancel_session_start(app_server).await); + }; + resumed }; let init = crate::chatwidget::ChatWidgetInit { local_settings: local_settings.clone(), @@ -691,6 +718,9 @@ See the Codex keymap documentation for supported actions and examples." if started.blocks_direct_input { app.mark_primary_thread_parent_owned(thread_id); } + if read_only_thread { + app.chat_widget.show_external_writer_thread(); + } match startup_draft .run_until( tui, @@ -701,7 +731,12 @@ See the Codex keymap documentation for supported actions and examples." Ok(result) => result?, Err(err) => return shutdown_on_startup_error(app_server, err).await, } - if should_prompt_for_paused_goal_after_startup_resume + if read_only_thread { + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.show_external_writer_thread(); + } + if !read_only_thread + && should_prompt_for_paused_goal_after_startup_resume && let Err(err) = startup_draft .run_until( tui, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d43ba70af6..6526014bd1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -568,11 +568,7 @@ async fn resolved_buffered_approval_does_not_become_actionable_after_drain() -> Ok(()) } -#[tokio::test] -async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> { - let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - let thread_id = ThreadId::new(); - let initial_prompt = "follow-up after replay".to_string(); +fn set_test_initial_prompt(app: &mut App, initial_prompt: String) { let config = app.config.clone(); let model = get_model_offline_for_tests(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { @@ -583,7 +579,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, initial_user_message: create_initial_user_message( - Some(initial_prompt.clone()), + Some(initial_prompt), Vec::new(), Vec::new(), ), @@ -602,6 +598,14 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(), session_telemetry: app.session_telemetry.clone(), }); +} + +#[tokio::test] +async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let initial_prompt = "follow-up after replay".to_string(); + set_test_initial_prompt(&mut app, initial_prompt.clone()); app.enqueue_primary_thread_session( test_thread_session(thread_id, test_path_buf("/tmp/project")), diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index 7738e20657..22a218125b 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -1,6 +1,95 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().await; + app.chat_widget.show_external_writer_thread(); + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + + app.keymap.app.open_external_editor = vec![crate::key_hint::ctrl(KeyCode::Char('g'))]; + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), + ) + .await; + assert_eq!( + app.chat_widget.external_editor_state(), + ExternalEditorState::Closed + ); + assert!( + !events + .try_recv() + .is_ok_and(|event| matches!(event, AppEvent::LaunchExternalEditor)) + ); + + for key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ] { + app.handle_key_event(&mut tui, &mut app_server, key).await; + assert!(matches!( + events.try_recv(), + Ok(AppEvent::Exit(ExitMode::Immediate)) + )); + } + Ok(()) +} + +#[tokio::test] +async fn external_writer_view_preserves_draft_from_keys_and_paste() -> Result<()> { + let (mut app, _events, _operations) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + app.app_server_target = AppServerTarget::Remote { + endpoint: crate::RemoteAppServerEndpoint::WebSocket { + websocket_url: "wss://example.com/".to_string(), + auth_token: None, + }, + }; + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.insert_str("Retained draft"); + app.chat_widget.show_external_writer_thread(); + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + for offline in [false, true] { + app.reconnect.offline = offline; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ) + .await?; + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("pasted".into())) + .await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + } + + app.reconnect.offline = false; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ) + .await?; + assert!(app.overlay.is_some()); + Ok(()) +} + #[tokio::test] async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> { let (mut app, _, _) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 62149c2b6e..aac8229467 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -41,6 +41,63 @@ use tokio_tungstenite::tungstenite::Message; pub(super) type RecordedRequests = Arc>>; pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle>); +#[tokio::test] +async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> { + let (mut app, codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let path = Some(rollout_path( + codex_home.path(), + "2026-01-01T00-00-00", + &thread_id.to_string(), + )); + let (mut app_server, requests, proxy) = start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await?; + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + app.ensure_thread_channel(thread_id).mark_external_writer(); + app.chat_widget.insert_str("Retained draft"); + app.chat_widget.show_external_writer_thread(); + app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf()); + requests.lock().expect("request recorder lock").clear(); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.resume_target_session( + &mut tui, + &mut app_server, + crate::resume_picker::SessionTarget { + path, + thread_id, + history_mode: None, + }, + ) + .await?; + assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); + assert!(recorded_params(&requests, "thread/unsubscribe").is_empty()); + assert!(!app.chat_widget.is_external_writer_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + proxy.abort(); + Ok(()) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum HistoryCapabilities { Current, diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index 85d91fc7df..da39ffa0ac 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1366,3 +1366,71 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread( assert!(!ignored); assert!(app.transcript_cells.is_empty()); } + +#[tokio::test] +async fn ignore_same_thread_resume_allows_retrying_read_only_view() -> Result<()> { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let session = test_thread_session(thread_id, test_path_buf("/tmp/project")); + app.chat_widget.handle_thread_session(session.clone()); + let mut channel = ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + session.clone(), + Vec::new(), + ); + channel.mark_external_writer(); + app.thread_event_channels.insert(thread_id, channel); + app.activate_thread_channel(thread_id).await; + + assert!( + !app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget { + path: Some(test_path_buf("/tmp/project")), + thread_id, + history_mode: None, + }) + ); + let app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.render_thread_snapshot( + &mut tui, + &app_server, + thread_id, + ThreadEventSnapshot { + session: Some(session), + turns: vec![test_turn("running", TurnStatus::InProgress, Vec::new())], + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + )?; + assert!(app.chat_widget.is_external_writer_view()); + assert!(!app.chat_widget.is_task_running_for_test()); + Ok(()) +} + +#[tokio::test] +async fn external_writer_startup_keeps_initial_prompt_as_draft() -> Result<()> { + let (mut app, mut events, _operations) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let prompt = "Continue after the other app closes"; + set_test_initial_prompt(&mut app, prompt.to_string()); + app.chat_widget.show_external_writer_thread(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + + assert_eq!(app.chat_widget.composer_text_with_pending(), prompt); + assert!(!std::iter::from_fn(|| events.try_recv().ok()).any(|event| { + matches!( + event, + AppEvent::SubmitThreadOp { + op: Op::UserTurn { .. }, + .. + } | AppEvent::CodexOp(Op::UserTurn { .. }) + ) + })); + Ok(()) +} diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index c5cbaf4a90..e192656dad 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -36,6 +36,7 @@ pub(super) struct FeedbackThreadEvent { pub(super) enum ThreadEventAttachment { Live, ReplayOnly, + ExternalWriter, } #[derive(Debug)] @@ -386,6 +387,10 @@ impl ThreadEventChannel { self.attachment = ThreadEventAttachment::ReplayOnly; } + pub(super) fn mark_external_writer(&mut self) { + self.attachment = ThreadEventAttachment::ExternalWriter; + } + pub(super) fn attachment(&self) -> ThreadEventAttachment { self.attachment } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 57e106f69b..b65b4d5997 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -16,10 +16,7 @@ use codex_app_server_protocol::WarningNotification; impl App { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { - let side_thread_ids: Vec = self.side_threads.keys().copied().collect(); - for side_thread_id in side_thread_ids { - self.discard_side_thread(app_server, side_thread_id).await; - } + self.shutdown_side_threads(app_server).await; if let Some(thread_id) = self.chat_widget.thread_id() { if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); @@ -28,6 +25,13 @@ impl App { } } + pub(super) async fn shutdown_side_threads(&mut self, app_server: &mut AppServerSession) { + let side_thread_ids: Vec = self.side_threads.keys().copied().collect(); + for side_thread_id in side_thread_ids { + self.discard_side_thread(app_server, side_thread_id).await; + } + } + pub(super) fn abort_thread_event_listener(&mut self, thread_id: ThreadId) { if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) { handle.abort(); @@ -175,7 +179,12 @@ impl App { &mut self, target_session: &crate::resume_picker::SessionTarget, ) -> bool { - if self.active_thread_id != Some(target_session.thread_id) { + if self.active_thread_id != Some(target_session.thread_id) + || self + .thread_event_channels + .get(&target_session.thread_id) + .is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live) + { return false; }; @@ -445,7 +454,7 @@ impl App { ) -> Result<()> { if self.thread_unavailable(thread_id) { self.chat_widget.add_error_message( - "This conversation is unavailable; no operation was sent.".into(), + "This conversation is read-only or unavailable; no operation was sent.".into(), ); return Ok(()); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 851e1d6f99..e26acc7302 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -349,6 +349,11 @@ pub(crate) struct AppServerStartedThread { pub(crate) task_tools_available: bool, } +pub(crate) fn is_active_writer_error(err: &color_eyre::eyre::Report) -> bool { + err.chain() + .any(|cause| cause.to_string().contains("already has an active writer")) +} + pub(crate) fn source_agent_path(source: &SessionSource) -> Option { match source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => { diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index eda2b53a50..4d0d411b7b 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -9,8 +9,10 @@ use super::bootstrap_request_error; use super::is_history_pagination_unsupported; use super::started_thread_from_resume_response; use super::thread_resume_params_from_config; +use super::thread_session_state_from_thread_response; use crate::legacy_core::config::Config; use codex_app_server_client::TypedRequestError; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadResumeResponse; @@ -19,6 +21,53 @@ use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; impl AppServerSession { + /// Read a conflicting thread without taking its writer lease. This is a snapshot, not an + /// attachment: the caller must not route turns through this session. + pub(crate) async fn read_thread_for_viewing( + &mut self, + config: &Config, + local_settings: &crate::local_settings::LocalSettings, + thread_id: ThreadId, + ) -> Result { + let mut thread = self.thread_read(thread_id, /*include_turns*/ false).await?; + self.hydrate_initial_thread_history( + &mut thread, + /*turn_cursor*/ None, + /*item_cursor*/ None, + Some(config), + Some(local_settings), + HistoryHydrationScope::Initial, + ) + .await?; + let session = thread_session_state_from_thread_response( + &thread.id, + thread.forked_from_id.clone(), + thread.name.clone(), + thread.path.clone(), + thread.model.clone().unwrap_or_default(), + thread.model_provider.clone(), + config.service_tier.clone(), + AskForApproval::from(config.permissions.approval_policy.value()), + config.approvals_reviewer, + config.permissions.permission_profile().clone(), + config.permissions.active_permission_profile(), + thread.cwd.clone(), + config.workspace_roots.clone(), + Vec::new(), + thread.reasoning_effort, + config.personality, + local_settings, + ) + .await + .map_err(color_eyre::eyre::Report::msg)?; + Ok(AppServerStartedThread { + session, + turns: thread.turns, + blocks_direct_input: false, + task_tools_available: false, + }) + } + pub(crate) fn with_local_codex_home(mut self, codex_home: &AbsolutePathBuf) -> Self { self.task_tool_capabilities_dir = (!self.uses_embedded_app_server()) .then(|| codex_home.join("tui-thread-reference-capabilities")); diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index 586421964b..1beabab80b 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -19,6 +19,49 @@ async fn build_config(temp_dir: &TempDir) -> Config { .expect("config should build") } +#[tokio::test] +async fn viewing_thread_reads_history_without_resuming_it() -> Result<()> { + let codex_home = tempfile::tempdir()?; + let config = build_config(&codex_home).await; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let next_request_id = app_server.next_request_id; + let viewed = app_server + .read_thread_for_viewing( + &config, + &crate::local_settings::LocalSettings::from(&config), + thread_id, + ) + .await?; + + assert_eq!(viewed.session.thread_id, thread_id); + assert!(!viewed.turns.is_empty()); + assert!(!viewed.blocks_direct_input); + assert!(app_server.next_request_id > next_request_id); + app_server.shutdown().await?; + Ok(()) +} + +#[test] +fn only_active_writer_failures_offer_read_only_view() { + let conflict = color_eyre::eyre::eyre!("thread already has an active writer (code -32600)"); + let unrelated = color_eyre::eyre::eyre!("thread not found (code -32600)"); + assert!(crate::app_server_session::is_active_writer_error(&conflict)); + assert!(!crate::app_server_session::is_active_writer_error( + &unrelated + )); +} + #[tokio::test] async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() -> Result<()> { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index ab4366e74a..997866be7a 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -870,7 +870,7 @@ pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 { footer_hint_items_line(items).width() as u16 } -fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> { +pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> { let mut spans = Vec::with_capacity(items.len() * 4); for (idx, (key, label)) in items.iter().enumerate() { spans.push(" ".into()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index aeff6e1f2e..f249fad8b2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -475,6 +475,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn transcript_shortcut_hint(&self) -> Option { + self.keymap + .primary_hint(KeymapContext::Global, "open_transcript") + } + /// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text. pub(crate) fn drain_pending_submission_state(&mut self) { let _ = self.take_recent_submission_images_with_placeholders(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bbd1a13cea..852eaa1ff6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -690,6 +690,7 @@ pub(crate) struct ChatWidget { thread_rename_block_message: Option, active_side_conversation: bool, blocks_direct_input: bool, + external_writer_view: bool, misalignment_policy_violation: Option, normal_placeholder_text: String, side_placeholder_text: String, @@ -1746,6 +1747,25 @@ impl ChatWidget { self.bottom_pane.clear_esc_backtrack_hint(); } + pub(crate) fn show_external_writer_thread(&mut self) { + self.blocks_direct_input = true; + self.external_writer_view = true; + self.pause_unavailable_thread(); + if let Some(cell) = self.transcript.active_cell.as_mut() { + if let Some(exec) = cell.as_any_mut().downcast_mut::() { + exec.freeze_snapshot(); + } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { + tool.freeze_snapshot(); + } + } + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); + } + + pub(crate) fn is_external_writer_view(&self) -> bool { + self.external_writer_view + } + fn refresh_skills_for_current_cwd(&mut self, force_reload: bool) { self.submit_op(AppCommand::list_skills( vec![self.config.cwd.to_path_buf()], @@ -1768,7 +1788,12 @@ impl ChatWidget { AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact ) { - self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + self.add_error_message(if self.external_writer_view { + "This thread is open elsewhere. Close it there and retry resume to continue." + .to_string() + } else { + PARENT_OWNED_INPUT_MESSAGE.to_string() + }); return false; } self.prepare_local_op_submission(&op); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 8d4d956a29..e2ad6d54c6 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -217,6 +217,7 @@ impl ChatWidget { thread_rename_block_message: None, active_side_conversation: false, blocks_direct_input: false, + external_writer_view: false, misalignment_policy_violation: None, normal_placeholder_text: placeholder, side_placeholder_text: side_placeholder, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 82f5560621..97d3e59401 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -265,7 +265,12 @@ impl ChatWidget { mut collaboration_mode: CollaborationModeMask, ) { if self.blocks_direct_input { - self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string()); + self.add_error_message(if self.external_writer_view { + "This thread is open elsewhere. Close it there and retry resume to continue." + .to_string() + } else { + PARENT_OWNED_INPUT_MESSAGE.to_string() + }); return; } if collaboration_mode.mode == Some(ModeKind::Plan) diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 5b0eedec52..e669e135be 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -271,7 +271,7 @@ impl ChatWidget { } pub(crate) fn can_launch_external_editor(&self) -> bool { - self.bottom_pane.can_launch_external_editor() + !self.external_writer_view && self.bottom_pane.can_launch_external_editor() } pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool { @@ -512,6 +512,9 @@ impl ChatWidget { } pub(crate) fn handle_paste(&mut self, text: String) { + if self.external_writer_view { + return; + } self.bottom_pane.handle_paste(text); } diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs index 2b7a96db37..1de24dcb74 100644 --- a/codex-rs/tui/src/chatwidget/reconnect.rs +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -67,6 +67,9 @@ impl ChatWidget { } pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + if self.external_writer_view { + return; + } if self.handle_question_key(key) { return; } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 693ede4edf..61a5403d27 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -3,9 +3,121 @@ use super::transcript::ActiveCellLayoutCache; use super::transcript::ActiveCellLayoutCacheKey; use super::*; +use crate::render::RectExt; use crate::terminal_hyperlinks::HyperlinkParagraph; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use ratatui::style::Styled as _; +use ratatui::text::Span; +use ratatui::widgets::Block; use std::cell::Cell; +struct ExternalWriterNotice { + transcript_hint: Option, +} + +impl Renderable for ExternalWriterNotice { + fn render(&self, area: Rect, buf: &mut Buffer) { + let content_width = area.width.saturating_sub(/*rhs*/ 4); + let card_lines = self.card_lines(content_width); + let card_height = (card_lines.len() as u16).saturating_add(/*rhs*/ 2); + let card = Rect::new(area.x, area.y, area.width, card_height.min(area.height)); + Widget::render( + Block::default().style(crate::style::user_message_style()), + card, + buf, + ); + let content = card.inset(Insets::tlbr( + /*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2, + )); + Renderable::render(&Paragraph::new(card_lines), content, buf); + let footer_y = card.bottom(); + if footer_y < area.bottom() { + let footer = Rect::new( + area.x.saturating_add(/*rhs*/ 2), + footer_y, + area.width.saturating_sub(/*rhs*/ 2), + area.bottom().saturating_sub(footer_y), + ); + Renderable::render( + &Paragraph::new(self.footer_lines(footer.width)), + footer, + buf, + ); + } + } + + fn desired_height(&self, width: u16) -> u16 { + (self.card_lines(width.saturating_sub(/*rhs*/ 4)).len() as u16) + .saturating_add(/*rhs*/ 2) + .saturating_add(self.footer_lines(width.saturating_sub(/*rhs*/ 2)).len() as u16) + } +} + +impl ExternalWriterNotice { + fn card_lines(&self, width: u16) -> Vec> { + let title: Line<'static> = vec![ + "🔒".into(), + " ".into(), + "This conversation is open in another app".bold(), + ] + .into(); + let retry: Line<'static> = vec![ + Span::styled("R", crate::style::accent_style()), + " to Retry".into(), + ] + .into(); + let mut lines = word_wrap_lines(&[title], usize::from(width)); + if lines.len() == 1 && lines[0].width() + retry.width() + 2 <= usize::from(width) { + let gap = usize::from(width) - lines[0].width() - retry.width(); + lines[0].spans.push(" ".repeat(gap).into()); + lines[0].spans.extend(retry.spans); + } else { + lines.push(retry); + } + lines.extend(word_wrap_lines( + &[Line::from( + "Close it there and press R to continue here.".dim(), + )], + RtOptions::new(usize::from(width)) + .initial_indent(" ".into()) + .subsequent_indent(" ".into()), + )); + lines + } + + fn footer_lines(&self, width: u16) -> Vec> { + let mut items = vec![ + ("r".to_string(), "retry".to_string()), + ( + format!( + "{}/{}/{}", + crate::key_hint::plain(KeyCode::Esc).display_label(), + crate::key_hint::ctrl(KeyCode::Char('c')).display_label(), + crate::key_hint::plain(KeyCode::Char('q')).display_label(), + ) + .replace(" + ", "+"), + "exit".to_string(), + ), + ]; + if let Some(hint) = self.transcript_hint { + items.push(( + hint.display_label().replace(" + ", "+"), + "transcript".to_string(), + )); + } + let mut spans = vec![" ".set_style(crate::style::footer_hint_label_style())]; + for (idx, (key, label)) in items.into_iter().enumerate() { + if idx > 0 { + spans.push(" ".set_style(crate::style::footer_hint_label_style())); + } + spans.push(key.set_style(crate::style::footer_hint_key_style())); + spans.push(format!(" {label}").set_style(crate::style::footer_hint_label_style())); + } + word_wrap_lines(&[Line::from(spans)], usize::from(width)) + } +} + impl ChatWidget { pub(crate) fn as_renderable(&self) -> RenderableItem<'_> { if self @@ -68,13 +180,19 @@ impl ChatWidget { })), ); } - flex.push( - /*flex*/ 0, + let bottom = if self.external_writer_view && !self.bottom_pane.has_active_view() { + RenderableItem::Owned(Box::new(ExternalWriterNotice { + transcript_hint: self.bottom_pane.transcript_shortcut_hint(), + })) + } else { self.bottom_pane .as_renderable_with_composer_right_reserve(active_cell_right_reserve) - .inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )), + }; + flex.push( + /*flex*/ 0, + bottom.inset(Insets::tlbr( + /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, + )), ); RenderableItem::Owned(Box::new(flex)) } diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index beac194da6..2d7abb97ae 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -75,6 +75,60 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool { }) } +#[tokio::test] +async fn external_writer_view_shows_notice_instead_of_composer() { + let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; + widget.show_external_writer_thread(); + + let frame = crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (230, 230, 230), + bg: (20, 20, 20), + }, + || render_frame(&widget, /*width*/ 60), + ); + let rows: Vec = frame + .content + .chunks(usize::from(frame.area.width)) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect(); + insta::assert_snapshot!( + rows.iter() + .map(|row| row.trim_end()) + .collect::>() + .join("\n") + ); + assert_ne!(frame[(0, 1)].bg, ratatui::style::Color::Reset); + assert_eq!(frame[(0, 1)].bg, frame[(59, 4)].bg); + assert!( + !frame[(3, 5)] + .style() + .add_modifier + .intersects(ratatui::style::Modifier::DIM | ratatui::style::Modifier::BOLD) + ); + assert!( + frame[(5, 5)] + .style() + .add_modifier + .contains(ratatui::style::Modifier::DIM) + ); + assert!(!widget.bottom_pane.composer_input_enabled()); +} + +#[tokio::test] +async fn external_writer_notice_uses_current_transcript_shortcut() { + let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; + let mut keymap = crate::keymap::RuntimeKeymap::defaults(); + keymap.app.open_transcript = vec![crate::key_hint::ctrl(KeyCode::Char('k'))]; + widget.bottom_pane.set_keymap_bindings(&keymap); + widget.show_external_writer_thread(); + + assert!(contains_text( + &render_frame(&widget, /*width*/ 80), + "ctrl+k transcript" + )); +} + #[test] fn active_transcript_preserves_clipped_markdown_hyperlinks() { let cell = history_cell::AgentMarkdownCell::new( diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap new file mode 100644 index 0000000000..e5d61211f2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__rendering__tests__external_writer_view_shows_notice_instead_of_composer.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/rendering_tests.rs +expression: "rows.iter().map(|row| row.trim_end()).collect::>().join(\"\\n\")" +--- + + + 🔒 This conversation is open in another app R to Retry + Close it there and press R to continue here. + + r retry esc/ctrl+c/q exit ctrl+t transcript diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 46f7fafd2e..012b4c8ebd 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1,6 +1,51 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn external_writer_snapshot_freezes_active_command_and_mcp_rows() { + let mut rendered = Vec::new(); + for active_mcp in [false, true] { + let (mut chat, _events, _operations) = + make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + if active_mcp { + chat.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + "mcp-running".to_string(), + McpInvocation { + server: "server".to_string(), + tool: "tool".to_string(), + arguments: None, + }, + /*animations_enabled*/ true, + ))); + } else { + begin_exec(&mut chat, "call-running", "sleep 5"); + } + + chat.show_external_writer_thread(); + + assert!(!chat.is_task_running_for_test()); + let cell = chat + .transcript + .active_cell + .as_ref() + .expect("active tool row"); + rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80))); + if active_mcp { + assert!(cell.transcript_animation_tick().is_none()); + } else { + assert!( + !cell + .as_any() + .downcast_ref::() + .expect("active command row") + .animations_enabled() + ); + } + } + insta::assert_snapshot!(rendered.join("\n---\n")); +} + #[tokio::test] async fn replayed_command_completion_preserves_tracking_without_duplicate_starts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__exec_flow__external_writer_snapshot_freezes_active_command_and_mcp_rows.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__exec_flow__external_writer_snapshot_freezes_active_command_and_mcp_rows.snap new file mode 100644 index 0000000000..b38704f703 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__exec_flow__external_writer_snapshot_freezes_active_command_and_mcp_rows.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/exec_flow.rs +expression: "rendered.join(\"\\n---\\n\")" +--- +• Running sleep 5 + +--- +• Calling server.tool() diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 0401b8c7fe..e5fc309e33 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -179,6 +179,10 @@ impl ExecCell { self.animations_enabled } + pub(crate) fn freeze_snapshot(&mut self) { + self.animations_enabled = false; + } + pub(crate) fn iter_calls(&self) -> impl Iterator { self.calls.iter() } diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index becdfac5d6..9ff53fd05e 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -120,6 +120,10 @@ impl McpToolCallCell { self.result = Some(Err("interrupted".to_string())); } + pub(crate) fn freeze_snapshot(&mut self) { + self.animations_enabled = false; + } + fn result_kind(&self) -> McpResultKind { if is_node_repl_backed_server(&self.invocation.server) && self.invocation.tool == "js" { McpResultKind::NodeRepl diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 81465b4165..6ad1f8c8af 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -21,6 +21,8 @@ use crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd a use crate::pager_overlay::Overlay; use crate::session_resume::resolve_session_thread_id; use crate::status::format_directory_display; +use crate::style::footer_hint_key_style; +use crate::style::footer_hint_label_style; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::text_formatting::truncate_text; @@ -2630,22 +2632,6 @@ fn fit_footer_hint_refs( Some(spans.into()) } -fn footer_hint_key_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::Black) - } else { - Style::default() - } -} - -fn footer_hint_label_style() -> Style { - if default_bg().is_some_and(is_light) { - Style::default().fg(Color::DarkGray) - } else { - Style::default().dim() - } -} - fn footer_hints_width( hints: &[&PickerFooterHint], mode: FooterHintLabelMode, diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 5961deea70..99a73757d3 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -61,6 +61,22 @@ pub(crate) fn accent_style() -> Style { accent_style_for(default_bg()) } +pub(crate) fn footer_hint_key_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::Black) + } else { + Style::default() + } +} + +pub(crate) fn footer_hint_label_style() -> Style { + if default_bg().is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + Style::default().dim() + } +} + /// Returns the style for a user-authored message using the provided terminal background. pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { match terminal_bg {