From fdad70ba5d373f7c679fc582a9cbedf216849934 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Thu, 12 Nov 2026 23:56:19 +0000 Subject: [PATCH] Hide TUI status details for read-only history (#61219) ## What changed Track whether a thread is being viewed without a live attachment. Hide the status line and context usage for saved history and unavailable conversations, even when token usage updates arrive. Restore these displays when the thread resumes interactively, including sessions using read-only permissions. Avoid a leading separator before the Vim mode indicator when context usage is hidden. ## Testing Add regression coverage for history viewing, token updates, and interactive resume. Update unavailable-conversation snapshots to omit the status line. GitOrigin-RevId: 14dfd2ee0f232001814b7fc45a5ac8a5d755669b --- codex-rs/tui/src/app/config_persistence.rs | 1 + .../tui/src/app/owned_transcript_tests.rs | 1 + codex-rs/tui/src/app/reconnect.rs | 3 + codex-rs/tui/src/app/session_lifecycle.rs | 3 + codex-rs/tui/src/app/tests.rs | 16 ++++- .../app/tests/session_lifecycle_requests.rs | 67 +++++++++++++++++++ ...e_reconnect__unavailable_conversation.snap | 2 - ..._reconnected_unavailable_conversation.snap | 2 - codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/app/thread_session_state.rs | 3 + codex-rs/tui/src/app_server_session.rs | 1 + .../src/app_server_session/rollout_history.rs | 3 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 12 ++-- .../chat_composer/embedded_input_tests.rs | 2 +- .../bottom_pane/chat_composer/footer_state.rs | 2 +- .../chat_composer/passive_hints.rs | 2 +- .../chat_composer/status_surface_tests.rs | 2 +- codex-rs/tui/src/bottom_pane/mod.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 12 ++-- codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/reconnect.rs | 3 + codex-rs/tui/src/chatwidget/session_flow.rs | 2 + .../tui/src/chatwidget/status_surfaces.rs | 3 + .../tui/src/chatwidget/tests/app_server.rs | 1 + .../chatwidget/tests/composer_submission.rs | 9 +++ .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 11 +++ .../tui/src/chatwidget/tests/permissions.rs | 3 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/history_cell/tests.rs | 1 + codex-rs/tui/src/session_state.rs | 2 + codex-rs/tui/src/startup_draft.rs | 2 +- 34 files changed, 160 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 220026e4d4..0e0a1402f4 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -1942,6 +1942,7 @@ enabled = false app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index e8b1919943..8ffbc10d30 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -35,6 +35,7 @@ pub(in crate::app) fn user_cell(message: &str) -> Arc { pub(in crate::app) fn attach_thread(app: &mut App, thread_id: ThreadId) { app.chat_widget.handle_thread_session(ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index e08871c026..fb82daa3fc 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -485,6 +485,9 @@ impl App { store.pending_interactive_replay = Default::default(); store.pending_interrupt_turn_id = None; store.active = false; + if let Some(session) = &mut store.session { + session.read_only_history = true; + } store .buffer .retain(|event| matches!(event, ThreadBufferedEvent::Notification(_))); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index b68e011730..e6590b5227 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -837,6 +837,9 @@ impl App { display, }); } + if is_replay_only && let Some(session) = snapshot.session.as_mut() { + session.read_only_history = true; + } self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; if !is_replay_only && let Some(message) = self diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e0c965f7d7..0544ded881 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -7469,6 +7469,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let primary_cwd = test_path_buf("/tmp/main").abs(); let shared_root = test_path_buf("/tmp/shared").abs(); let primary_session = ThreadSessionState { + read_only_history: true, daybreak_enabled: false, windows_sandbox_host: WindowsSandboxHost::Local, approval_policy: AskForApproval::OnRequest, @@ -7546,6 +7547,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re let session = store.session.clone().expect("inferred session"); drop(store); + assert!(!session.read_only_history); assert_eq!(session.thread_id, agent_thread_id); assert!(session.daybreak_enabled); assert_eq!(session.windows_sandbox_host, WindowsSandboxHost::Remote); @@ -7674,7 +7676,6 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { runtime_workspace_roots: vec![primary_cwd.clone()], ..test_thread_session(main_thread_id, primary_cwd.to_path_buf()) }; - app.primary_session_configured = Some(primary_session); let thread = Thread { originator: None, @@ -7712,10 +7713,17 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { turns: Vec::new(), }; + let cold_session = app + .session_state_for_thread_read(read_thread_id, &thread) + .await; + assert!(cold_session.read_only_history); + + app.primary_session_configured = Some(primary_session); let session = app .session_state_for_thread_read(read_thread_id, &thread) .await; + assert!(session.read_only_history); assert_eq!(session.thread_id, read_thread_id); assert_eq!(session.model, "server-reported-model"); assert_eq!(session.cwd.as_path(), test_path_buf("/tmp/read").as_path()); @@ -8820,6 +8828,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { }; let make_header = |is_first| -> Arc { let session = ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -9533,6 +9542,7 @@ async fn replace_goal_confirmation_snapshot() { pub(super) fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -10849,6 +10859,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let make_header = |is_first| { let session = ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -10922,6 +10933,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { let base_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: base_id, @@ -12318,6 +12330,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { let thread_id = ThreadId::new(); let event = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -13097,6 +13110,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { let thread_id = ThreadId::new(); app.chat_widget .handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, 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 0f52a18283..0e3a2b6094 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1873,6 +1873,73 @@ async fn make_history_test_app() -> Result<(Box, tempfile::TempDir)> { Ok((app, codex_home)) } +#[tokio::test] +async fn read_only_history_hides_the_statusline_until_interactive_resume() -> Result<()> { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "saved prompt")?; + app.config + .permissions + .set_permission_profile(PermissionProfile::read_only())?; + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + app.chat_widget.setup_status_line( + vec![crate::bottom_pane::StatusLineItem::SessionId], + /*use_theme_colors*/ false, + ); + + app.enqueue_primary_thread_session( + test_thread_session(thread_id, app.config.cwd.to_path_buf()), + Vec::new(), + ) + .await?; + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains(&thread_id.to_string())); + + let (view, _) = server + .read_thread_for_viewing(&app.config, &app.local_settings, thread_id) + .await?; + app.enqueue_primary_thread_session(view.session, view.turns) + .await?; + let footer = render_bottom_popup(&app.chat_widget, /*width*/ 100); + insta::assert_snapshot!(footer, @r" + › Ask Codex to do anything + + ? for shortcuts + "); + + for window in [Some(100_000), None] { + app.chat_widget.handle_server_notification( + token_usage_notification(thread_id, "turn-1", window), + /*replay_kind*/ None, + ); + assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 100), footer); + } + + let live = server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + app.resume_model_settings(), + ) + .await?; + assert_eq!( + live.session.permission_profile, + PermissionProfile::read_only() + ); + app.enqueue_primary_thread_session(live.session, live.turns) + .await?; + let footer = render_bottom_popup(&app.chat_widget, /*width*/ 100); + assert!(footer.contains(&thread_id.to_string()), "{footer}"); + app.chat_widget + .setup_status_line(Vec::new(), /*use_theme_colors*/ false); + app.chat_widget.handle_server_notification( + token_usage_notification(thread_id, "turn-2", /*model_context_window*/ None), + /*replay_kind*/ None, + ); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("10 used")); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn cloud_resume_with_current_directory_needs_no_cwd_override() -> Result<()> { use codex_config::types::ResumeCwdMode; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap index 335071a077..b3e67c6251 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__active_reconnect__unavailable_conversation.snap @@ -6,5 +6,3 @@ expression: "format!(\"{history}\\n{}\",\nrender_bottom_popup(&app.chat_widget,\ ⚠ A background Codex service is running v2.0.0, different from your Codex CLI v0.0.0. › unavailable draft - - gpt-test default · /tmp/project diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap index 432d5cf464..001edd53b3 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__navigation_reconnect__reconnected_unavailable_conversation.snap @@ -7,5 +7,3 @@ Cached previous conversation • 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. • The rename target disappeared. Unsubmitted title: Keep this task draft! › latest draft - - gpt-test default · /tmp/project diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 05439859cf..3d0079c2a4 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -744,6 +744,7 @@ mod tests { fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 44a1f64e0e..c579aef87d 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1430,6 +1430,7 @@ impl App { notification: &ThreadStartedNotification, ) -> Option { let mut session = self.primary_session_configured.clone()?; + session.read_only_history = false; session.thread_id = thread_id; // The primary session supplies defaults. The new thread supplies its own fork parent. session.set_fork_parent(notification.thread.forked_from_id.as_deref()); diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index b1ab8ad1af..ea3b3b1138 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -78,6 +78,7 @@ impl App { let permission_profile = self.current_permission_profile(); let active_permission_profile = self.current_active_permission_profile(); let mut session = if let Some(mut session) = self.primary_session_configured.clone() { + session.read_only_history = true; if session.thread_id != thread_id { // `thread/read` does not include all thread settings, so do not carry // thread-scoped state from the currently active session. @@ -87,6 +88,7 @@ impl App { session } else { ThreadSessionState { + read_only_history: true, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -174,6 +176,7 @@ mod tests { fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState { ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ec48e09b41..d2676ef21b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -3111,6 +3111,7 @@ async fn thread_session_state_from_thread_response( ); let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host, thread_id, 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 b0dcacca88..7ed5abcbe8 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -76,7 +76,7 @@ impl AppServerSession { "Showing up to 100 recent prompts and final replies. Intermediate messages and tool activity are unavailable.", ); } - let session = thread_session_state_from_thread_response( + let mut session = thread_session_state_from_thread_response( &thread.id, crate::windows_sandbox::host_from_environments(thread.environments.as_deref()), thread.forked_from_id.clone(), @@ -97,6 +97,7 @@ impl AppServerSession { ) .await .map_err(color_eyre::eyre::Report::msg)?; + session.read_only_history = true; self.freeze_viewed_receipt(thread_id); Ok(( AppServerStartedThread { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e489134a1b..23e499ebb1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -790,7 +790,7 @@ impl ChatComposer { flash: None, context_window_percent: None, context_window_used_tokens: None, - context_window_pending: false, + context_window_hidden: false, collaboration_mode_indicator: None, goal_status_indicator: None, ide_context_active: false, @@ -1496,7 +1496,7 @@ impl ChatComposer { } fn right_footer_line_with_context(&self) -> Line<'static> { - let mut line = if self.footer.context_window_pending { + let mut line = if self.footer.context_window_hidden { Line::default() } else { context_window_line( @@ -1505,7 +1505,9 @@ impl ChatComposer { ) }; if let Some(vim_mode) = self.vim_mode_indicator_span() { - line.spans.push(" | ".dim()); + if !line.spans.is_empty() { + line.spans.push(" | ".dim()); + } line.spans.push(vim_mode); } line @@ -4472,8 +4474,8 @@ impl ChatComposer { self.footer.context_window_used_tokens = used_tokens; } - pub(crate) fn set_context_window_pending(&mut self, pending: bool) { - self.footer.context_window_pending = pending; + pub(crate) fn set_context_window_hidden(&mut self, hidden: bool) { + self.footer.context_window_hidden = hidden; } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs index 0a79c52063..b5589bfee1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/embedded_input_tests.rs @@ -55,7 +55,7 @@ fn embedded_composer_shows_validation_error_without_losing_draft() { let (mut composer, mut rx) = new_test_composer(); composer.config = ChatComposerConfig::plain_text(); composer.set_footer_hint_override(Some(vec![("enter".into(), "submit".into())])); - composer.set_context_window_pending(/*pending*/ true); + composer.set_context_window_hidden(/*hidden*/ true); let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); composer.handle_paste(text.clone()); assert_eq!( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 6d6efe7d02..a2daadb6c2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -379,7 +379,7 @@ pub(super) struct FooterState { pub(super) flash: Option, pub(super) context_window_percent: Option, pub(super) context_window_used_tokens: Option, - pub(super) context_window_pending: bool, + pub(super) context_window_hidden: bool, pub(super) collaboration_mode_indicator: Option, pub(super) goal_status_indicator: Option, pub(super) ide_context_active: bool, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs b/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs index 4aac82f219..51ce6f3fec 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/passive_hints.rs @@ -329,7 +329,7 @@ impl ChatComposer { } let show_warning = self.show_warning_notice(options); if !self.footer.status_line_enabled - && !self.footer.context_window_pending + && !self.footer.context_window_hidden && self.footer.side_conversation_context_label.is_none() && !self.is_bang_shell_command() { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs index 3ad9a7aeff..9a02def3c6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface_tests.rs @@ -871,7 +871,7 @@ fn oversized_goal_yields_to_an_eligible_whole_hint_with_status_disabled() { Some("100% context left") ); - composer.set_context_window_pending(/*pending*/ true); + composer.set_context_window_hidden(/*hidden*/ true); composer.set_goal_status_indicator(Some(GoalStatusIndicator::Paused)); let (text, _) = render(&composer, /*width*/ 18, /*footer*/ None); assert_eq!(text.lines().last().map(str::trim), Some("? for shortcuts")); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 005dc50be8..b4e79d173e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1503,8 +1503,8 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_context_window_pending(&mut self, pending: bool) { - self.composer.set_context_window_pending(pending); + pub(crate) fn set_context_window_hidden(&mut self, hidden: bool) { + self.composer.set_context_window_hidden(hidden); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fe00942844..4380cb2353 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -688,6 +688,7 @@ pub(crate) struct ChatWidget { active_side_conversation: bool, blocks_direct_input: bool, external_writer_view: bool, + read_only_history: bool, provider_error: Option, /// Covers both queued and executing forks so repeated shortcuts cannot queue another one. pub(crate) fork_in_progress: bool, @@ -1161,13 +1162,17 @@ impl ChatWidget { self.config.memories.generate_memories = generate_memories; } + fn sync_context_window_visibility(&mut self) { + self.bottom_pane + .set_context_window_hidden(self.read_only_history || self.token_usage_pending); + } + pub(crate) fn set_token_info(&mut self, info: Option) { match info { Some(info) => self.apply_token_info(info), None => { self.token_usage_pending = true; - self.bottom_pane - .set_context_window_pending(/*pending*/ true); + self.sync_context_window_visibility(); self.bottom_pane .set_context_window(/*percent*/ None, /*used_tokens*/ None); self.token_info = None; @@ -1177,8 +1182,7 @@ impl ChatWidget { fn apply_token_info(&mut self, info: TokenUsageInfo) { self.token_usage_pending = false; - self.bottom_pane - .set_context_window_pending(/*pending*/ false); + self.sync_context_window_visibility(); let percent = self.context_remaining_percent(&info); let used_tokens = self.context_used_tokens(&info, percent.is_some()); self.bottom_pane.set_context_window(percent, used_tokens); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 7f23d54cde..8eff912f62 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -246,6 +246,7 @@ impl ChatWidget { active_side_conversation: false, blocks_direct_input: false, external_writer_view: false, + read_only_history: false, provider_error: None, fork_in_progress: false, misalignment_policy_violation: None, diff --git a/codex-rs/tui/src/chatwidget/reconnect.rs b/codex-rs/tui/src/chatwidget/reconnect.rs index dc2855b78b..262d1ad920 100644 --- a/codex-rs/tui/src/chatwidget/reconnect.rs +++ b/codex-rs/tui/src/chatwidget/reconnect.rs @@ -130,6 +130,9 @@ impl ChatWidget { } pub(crate) fn pause_unavailable_thread(&mut self) { + self.read_only_history = true; + self.sync_context_window_visibility(); + self.refresh_status_line(); if let Some(questions) = &mut self.bottom_pane.questions { questions.delivery_enabled = false; } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index f9d4ca9827..c6e21f61eb 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -96,6 +96,8 @@ impl ChatWidget { let connector_scope_changed = previous_thread_id != Some(session.thread_id) || self.config.cwd.as_path() != session.cwd.as_path(); self.thread_id = Some(session.thread_id); + self.read_only_history = session.read_only_history; + self.sync_context_window_visibility(); self.daybreak_enabled = self.config.features.enabled(Feature::CliDaybreak) && session.daybreak_enabled && !matches!(display, SessionConfiguredDisplay::SideConversation); diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 551cc6d238..79f1392f01 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -465,6 +465,9 @@ impl ChatWidget { /// /// Unknown ids are deduplicated in insertion order for warning messages. fn status_line_items_with_invalids(&self) -> (Vec, Vec) { + if self.read_only_history { + return Default::default(); + } parse_items_with_invalids(self.configured_status_line_items()) } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index f10acfc397..b282c88491 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -47,6 +47,7 @@ fn thread_settings_for_test( fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index bc41cfa14e..23834399dc 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -629,6 +629,7 @@ async fn submission_preserves_text_elements_and_local_images() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -739,6 +740,7 @@ async fn submission_includes_configured_active_permission_profile() { }; let expected_active_permission_profile = ActivePermissionProfile::new("custom"); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -795,6 +797,7 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { file_system: ManagedFileSystemPermissions::Unrestricted, }; let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -841,6 +844,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -942,6 +946,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1012,6 +1017,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1054,6 +1060,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1096,6 +1103,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1141,6 +1149,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 735e01f04c..229667c4af 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1269,6 +1269,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 0eac898bc3..59ac521103 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -22,6 +22,7 @@ async fn resumed_initial_messages_render_history() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let mut configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: true, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -184,6 +185,7 @@ async fn restored_conversation_ultra_remains_selected_after_switching_to_plan() chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -507,6 +509,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -580,6 +583,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -687,6 +691,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .expect("permission profile should project to legacy sandbox policy"); let expected_sandbox = SandboxPolicy::from(expected_core_sandbox); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -768,6 +773,7 @@ async fn session_configured_preserves_profile_workspace_roots() { let session_permission_profile = PermissionProfile::workspace_write() .materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -816,6 +822,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { network_access: NetworkAccess::Restricted, }; let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -858,6 +865,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -919,6 +927,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1277,6 +1286,7 @@ async fn replayed_reasoning_item_preserves_summary_parts_and_hides_raw_reasoning let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = false; chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -1329,6 +1339,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = true; chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index ba06f624ed..635962a9a0 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1162,6 +1162,7 @@ async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { create_initial_user_message(Some(initial_prompt.clone()), Vec::new(), Vec::new()); chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -1612,6 +1613,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure .set_enabled(Feature::GuardianApproval, /*enabled*/ true); chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), @@ -1666,6 +1668,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w let permission_profile = app_server_workspace_write_profile(extra_root); chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index a87c0edbb6..d3864cc99d 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -16,6 +16,7 @@ fn paste_hidden_plan_shell_payload(chat: &mut ChatWidget) -> String { fn plan_test_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, @@ -1229,6 +1230,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 2b56756645..f75df543d4 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -4719,6 +4719,7 @@ async fn session_configured_clears_goal_status_footer() { let rollout_file = NamedTempFile::new().unwrap(); chat.handle_thread_session(crate::session_state::ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 14ac4d99f5..dc317ebbad 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -622,6 +622,7 @@ fn image_generation_call_renders_saved_path() { fn session_configured_event(model: &str) -> ThreadSessionState { ThreadSessionState { + read_only_history: false, daybreak_enabled: false, windows_sandbox_host: crate::app::WindowsSandboxHost::Local, thread_id: ThreadId::new(), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 62e55d2732..3c13dc0dfa 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -27,6 +27,8 @@ pub(crate) struct MessageHistoryMetadata { #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadSessionState { + /// This TUI opened saved history without a live attachment. + pub(crate) read_only_history: bool, pub(crate) daybreak_enabled: bool, pub(crate) windows_sandbox_host: crate::app::WindowsSandboxHost, pub(crate) thread_id: ThreadId, diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index e55d014b18..95ad288d80 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -544,7 +544,7 @@ fn startup_draft_bottom_pane( ..ChatComposerConfig::plain_text() }, ); - bottom_pane.set_context_window_pending(/*pending*/ true); + bottom_pane.set_context_window_hidden(/*hidden*/ true); bottom_pane }