From 9803d01715d822c55bae721525bc3a357f379ccb Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Mon, 16 Mar 2026 11:22:52 -0700 Subject: [PATCH] tui: show rollback turn-context diffs Co-authored-by: Codex --- .../src/protocol/thread_history.rs | 10 +- codex-rs/core/src/codex.rs | 42 ++++- .../src/codex/rollout_reconstruction_tests.rs | 51 +++++- codex-rs/core/src/codex_tests.rs | 130 ++++++++++++- codex-rs/core/src/rollout/recorder_tests.rs | 3 + codex-rs/core/src/rollout/truncation_tests.rs | 1 + codex-rs/core/tests/suite/resume_warning.rs | 2 + codex-rs/protocol/src/protocol.rs | 24 +++ codex-rs/state/src/extract.rs | 4 + codex-rs/tui/src/app.rs | 17 +- codex-rs/tui/src/app_backtrack.rs | 19 +- codex-rs/tui/src/chatwidget.rs | 148 +++++++++++++++ ...lback_turn_context_diff_info_messages.snap | 12 ++ codex-rs/tui/src/chatwidget/tests.rs | 94 +++++++++- codex-rs/tui/src/history_cell.rs | 12 ++ codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/pager_overlay.rs | 70 ++++++- ...lay_highlighted_turn_context_snapshot.snap | 20 ++ codex-rs/tui/src/turn_context.rs | 171 ++++++++++++++++++ codex-rs/tui_app_server/src/app.rs | 10 +- .../tui_app_server/src/chatwidget/tests.rs | 11 +- codex-rs/tui_app_server/src/lib.rs | 2 + codex-rs/tui_app_server/src/multi_agents.rs | 1 + 23 files changed, 824 insertions(+), 33 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_thread_rollback_turn_context_diff_info_messages.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_highlighted_turn_context_snapshot.snap create mode 100644 codex-rs/tui/src/turn_context.rs diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 6a570b7fa5..cd9407b84d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1491,7 +1491,10 @@ mod tests { message: "A2".into(), phase: None, }), - EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), EventMsg::UserMessage(UserMessageEvent { message: "Third".into(), images: None, @@ -1574,7 +1577,10 @@ mod tests { message: "A2".into(), phase: None, }), - EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 99 }), + EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 99, + rolled_back_to_turn_context: None, + }), ]; let items = events diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index abed28fd7a..a580d65cb5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -199,6 +199,12 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } +#[derive(Clone, Debug, Default)] +struct AppliedRolloutReconstruction { + previous_turn_settings: Option, + surviving_turn_context_item: Option, +} + use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; use crate::file_watcher::FileWatcher; @@ -941,9 +947,11 @@ impl TurnContext { current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), + approvals_reviewer: self.config.approvals_reviewer, sandbox_policy: self.sandbox_policy.get().clone(), network: self.turn_context_network_item(), model: self.model_info.slug.clone(), + service_tier: self.config.service_tier, personality: self.personality, collaboration_mode: Some(self.collaboration_mode.clone()), realtime_active: Some(self.realtime_active), @@ -2076,9 +2084,10 @@ impl Session { } InitialHistory::Resumed(resumed_history) => { let rollout_items = resumed_history.history; - self.apply_rollout_reconstruction(&turn_context, &rollout_items) + let reconstructed_rollout = self + .apply_rollout_reconstruction(&turn_context, &rollout_items) .await; - let previous_turn_settings = self.previous_turn_settings().await; + let previous_turn_settings = reconstructed_rollout.previous_turn_settings; // If resuming, warn when the last recorded model differs from the current one. let curr: &str = turn_context.model_info.slug.as_str(); @@ -2153,15 +2162,25 @@ impl Session { &self, turn_context: &TurnContext, rollout_items: &[RolloutItem], - ) { + ) -> AppliedRolloutReconstruction { let reconstructed_rollout = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; + let previous_turn_settings = reconstructed_rollout + .reference_turn_context_state + .previous_turn_settings(); + let surviving_turn_context_item = reconstructed_rollout + .reference_turn_context_state + .latest_turn_context_item(); let mut state = self.state.lock().await; state.replace_history_with_reference_turn_context_state( reconstructed_rollout.history, reconstructed_rollout.reference_turn_context_state, ); + AppliedRolloutReconstruction { + previous_turn_settings, + surviving_turn_context_item, + } } fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option { @@ -5068,19 +5087,26 @@ mod handlers { } }; - let rollback_event = ThreadRolledBackEvent { num_turns }; - let rollback_msg = EventMsg::ThreadRolledBack(rollback_event.clone()); + let rollback_msg = EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns, + rolled_back_to_turn_context: None, + }); let replay_items = initial_history .get_rollout_items() .into_iter() .chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone()))) .collect::>(); + let reconstructed_rollout = sess + .apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()) + .await; + sess.recompute_token_usage(turn_context.as_ref()).await; + let rollback_msg = EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns, + rolled_back_to_turn_context: reconstructed_rollout.surviving_turn_context_item, + }); sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())]) .await; sess.flush_rollout().await; - sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice()) - .await; - sess.recompute_token_usage(turn_context.as_ref()).await; sess.deliver_event_raw(Event { id: turn_context.sub_id.clone(), diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index c0173517e9..189d8cf17c 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -45,9 +45,11 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -84,9 +86,11 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -215,7 +219,10 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -313,7 +320,10 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc )), RolloutItem::ResponseItem(turn_two_user), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -438,7 +448,10 @@ async fn reconstruct_history_rollback_backfills_surviving_turn_context_from_olde }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -565,7 +578,10 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -632,7 +648,10 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 99 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 99, + rolled_back_to_turn_context: None, + }, )), ]; @@ -702,7 +721,10 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { }, )), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -771,7 +793,10 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp replacement_history: Some(Vec::new()), }), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( - codex_protocol::protocol::ThreadRolledBackEvent { num_turns: 1 }, + codex_protocol::protocol::ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }, )), ]; @@ -934,9 +959,11 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1006,9 +1033,11 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1035,9 +1064,11 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1141,9 +1172,11 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: current_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1243,9 +1276,11 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1387,9 +1422,11 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index b8043ed8cc..36a742b00b 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1043,9 +1043,11 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_policy: turn_context.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: turn_context.config.service_tier, personality: turn_context.personality, collaboration_mode: Some(turn_context.collaboration_mode.clone()), realtime_active: Some(turn_context.realtime_active), @@ -1284,6 +1286,12 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; let rollback_event = wait_for_thread_rolled_back(&rx).await; assert_eq!(rollback_event.num_turns, 1); + assert_eq!( + serde_json::to_value(rollback_event.rolled_back_to_turn_context) + .expect("serialize rollback turn context"), + serde_json::to_value(Some(first_context_item.clone())) + .expect("serialize expected rollback turn context") + ); assert_eq!( sess.clone_history().await.raw_items(), @@ -1385,7 +1393,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; sess.replace_history( vec![assistant_message("stale history")], - Some(first_context_item), + Some(first_context_item.clone()), ) .await; @@ -1395,6 +1403,126 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio assert_eq!(sess.clone_history().await.raw_items(), compacted_history); assert!(sess.reference_context_item().await.is_none()); + assert_eq!( + serde_json::to_value(rollback_event.rolled_back_to_turn_context) + .expect("serialize rollback turn context"), + serde_json::to_value(Some(first_context_item)) + .expect("serialize expected rollback turn context") + ); +} + +#[tokio::test] +async fn thread_rollback_emits_surviving_turn_context_when_compaction_clears_reference_baseline() { + let (sess, tc, rx) = make_session_and_context_with_rx().await; + attach_rollout_recorder(&sess).await; + + let first_context_item = tc.to_turn_context_item(); + let first_turn_id = first_context_item + .turn_id + .clone() + .expect("turn context should have turn_id"); + let compact_turn_id = "compact-turn".to_string(); + let rolled_back_turn_id = "rolled-back-turn".to_string(); + let compacted_context_item = TurnContextItem { + turn_id: Some(compact_turn_id.clone()), + model: "compacted-model".to_string(), + ..first_context_item.clone() + }; + let compacted_history = vec![ + user_message("turn 1 user"), + assistant_message("turn 1 assistant"), + user_message("summary after compaction"), + ]; + + sess.persist_rollout_items(&[ + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: first_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 1 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(first_context_item.clone()), + RolloutItem::ResponseItem(user_message("turn 1 user")), + RolloutItem::ResponseItem(assistant_message("turn 1 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: first_turn_id, + last_agent_message: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: compact_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 2 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(compacted_context_item.clone()), + RolloutItem::Compacted(CompactedItem { + message: "summary after compaction".to_string(), + replacement_history: Some(compacted_history.clone()), + }), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: compact_turn_id, + last_agent_message: None, + })), + RolloutItem::EventMsg(EventMsg::TurnStarted( + codex_protocol::protocol::TurnStartedEvent { + turn_id: rolled_back_turn_id.clone(), + model_context_window: Some(128_000), + collaboration_mode_kind: ModeKind::Default, + }, + )), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "turn 3 user".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::TurnContext(TurnContextItem { + turn_id: Some(rolled_back_turn_id.clone()), + model: "rolled-back-model".to_string(), + ..first_context_item.clone() + }), + RolloutItem::ResponseItem(user_message("turn 3 user")), + RolloutItem::ResponseItem(assistant_message("turn 3 assistant")), + RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: rolled_back_turn_id, + last_agent_message: None, + })), + ]) + .await; + + handlers::thread_rollback(&sess, "sub-1".to_string(), 1).await; + let rollback_event = wait_for_thread_rolled_back(&rx).await; + + assert_eq!(rollback_event.num_turns, 1); + assert_eq!( + serde_json::to_value(rollback_event.rolled_back_to_turn_context) + .expect("serialize rollback turn context"), + serde_json::to_value(Some(compacted_context_item)) + .expect("serialize expected rollback turn context") + ); + assert_eq!(sess.clone_history().await.raw_items(), compacted_history); + assert_eq!( + sess.previous_turn_settings().await, + Some(PreviousTurnSettings { + model: "compacted-model".to_string(), + realtime_active: Some(tc.realtime_active), + }) + ); + assert!(sess.reference_context_item().await.is_none()); } #[tokio::test] diff --git a/codex-rs/core/src/rollout/recorder_tests.rs b/codex-rs/core/src/rollout/recorder_tests.rs index f6f588574a..5fc92abcc9 100644 --- a/codex-rs/core/src/rollout/recorder_tests.rs +++ b/codex-rs/core/src/rollout/recorder_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::config::ConfigBuilder; use crate::features::Feature; use chrono::TimeZone; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; @@ -480,9 +481,11 @@ async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Re current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, model: "test-model".to_string(), + service_tier: None, personality: None, collaboration_mode: None, realtime_active: None, diff --git a/codex-rs/core/src/rollout/truncation_tests.rs b/codex-rs/core/src/rollout/truncation_tests.rs index f7dd206264..894d81d39b 100644 --- a/codex-rs/core/src/rollout/truncation_tests.rs +++ b/codex-rs/core/src/rollout/truncation_tests.rs @@ -102,6 +102,7 @@ fn truncates_rollout_from_start_applies_thread_rollback_markers() { RolloutItem::ResponseItem(assistant_msg("a2")), RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1, + rolled_back_to_turn_context: None, })), RolloutItem::ResponseItem(user_msg("u3")), RolloutItem::ResponseItem(assistant_msg("a3")), diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index b1e6a694ca..d77c40bb1a 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -32,9 +32,11 @@ fn resume_history( current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: config.approvals_reviewer, sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, model: previous_model.to_string(), + service_tier: config.service_tier, personality: None, collaboration_mode: None, realtime_active: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1f60e163b..0adf5640cb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2457,10 +2457,15 @@ pub struct TurnContextItem { #[serde(default, skip_serializing_if = "Option::is_none")] pub timezone: Option, pub approval_policy: AskForApproval, + #[serde(default)] + pub approvals_reviewer: ApprovalsReviewer, pub sandbox_policy: SandboxPolicy, #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub service_tier: Option, #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2750,6 +2755,10 @@ pub struct UndoCompletedEvent { pub struct ThreadRolledBackEvent { /// Number of user turns that were removed from context. pub num_turns: u32, + /// Historical turn context for the turn that remains selected after rollback. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub rolled_back_to_turn_context: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -4253,6 +4262,8 @@ mod tests { assert_eq!(item.trace_id, None); assert_eq!(item.network, None); + assert_eq!(item.approvals_reviewer, ApprovalsReviewer::User); + assert_eq!(item.service_tier, None); Ok(()) } @@ -4265,12 +4276,14 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::DangerFullAccess, network: Some(TurnContextNetworkItem { allowed_domains: vec!["api.example.com".to_string()], denied_domains: vec!["blocked.example.com".to_string()], }), model: "gpt-5".to_string(), + service_tier: None, personality: None, collaboration_mode: None, realtime_active: None, @@ -4293,6 +4306,17 @@ mod tests { Ok(()) } + #[test] + fn thread_rolled_back_event_deserializes_without_turn_context() -> Result<()> { + let event: ThreadRolledBackEvent = serde_json::from_value(json!({ + "num_turns": 2, + }))?; + + assert_eq!(event.num_turns, 2); + assert!(event.rolled_back_to_turn_context.is_none()); + Ok(()) + } + /// Serialize Event to verify that its JSON representation has the expected /// amount of nesting. #[test] diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ba425adbec..853d36ab18 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -268,9 +268,11 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::DangerFullAccess, network: None, model: "gpt-5".to_string(), + service_tier: None, personality: None, collaboration_mode: None, realtime_active: None, @@ -306,9 +308,11 @@ mod tests { current_date: None, timezone: None, approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), network: None, model: "gpt-5".to_string(), + service_tier: None, personality: None, collaboration_mode: None, realtime_active: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 898a45fa05..6734bda352 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -6215,6 +6215,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -6981,6 +6982,7 @@ guardian_approval = true text_elements, local_image_paths, remote_image_urls, + turn_context: None, }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -7112,6 +7114,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc]; app.chat_widget .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); @@ -7170,6 +7173,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: vec![data_image_url.clone()], + turn_context: None, }) as Arc]; app.apply_backtrack_rollback(BacktrackSelection { @@ -7237,7 +7241,10 @@ guardian_approval = true local_images: Vec::new(), text_elements: Vec::new(), }), - EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), EventMsg::UserMessage(UserMessageEvent { message: "third prompt".to_string(), images: None, @@ -7327,7 +7334,10 @@ guardian_approval = true // Simulate a live rollback arriving before queued replay inserts are drained. app.handle_codex_event_now(Event { id: "live-rollback".to_string(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); let mut saw_rollback = false; @@ -7370,6 +7380,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new( vec![Line::from("after first")], @@ -7380,6 +7391,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new( vec![Line::from("after second")], @@ -7515,6 +7527,7 @@ guardian_approval = true text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc]; app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); app.deferred_history_lines = vec![Line::from("stale buffered line")]; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 1bb2c29569..442a87f713 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -338,13 +338,21 @@ impl App { fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) { if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) { self.backtrack.nth_user_message = nth_user_message; + let highlight_context_lines = self + .transcript_cells + .get(cell_idx) + .and_then(|cell| cell.as_any().downcast_ref::()) + .and_then(|cell| cell.turn_context.as_ref()) + .and_then(|snapshot| { + snapshot.diff_lines(&self.chat_widget.current_turn_context_snapshot()) + }); if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(Some(cell_idx)); + t.set_highlight_cell(Some(cell_idx), highlight_context_lines); } } else { self.backtrack.nth_user_message = usize::MAX; if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.set_highlight_cell(None); + t.set_highlight_cell(None, None); } } } @@ -671,6 +679,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -690,6 +699,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -721,6 +731,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, @@ -729,6 +740,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, @@ -775,6 +787,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new( vec![Line::from("after first")], @@ -785,6 +798,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new( vec![Line::from("after second")], @@ -813,6 +827,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c692f26590..01902d8c8d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,7 @@ use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::UndoCompletedEvent; use codex_protocol::protocol::UndoStartedEvent; @@ -269,6 +270,7 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +use crate::turn_context::TurnContextSnapshot; mod interrupts; use self::interrupts::InterruptManager; mod agent; @@ -5014,6 +5016,7 @@ impl ChatWidget { } let effective_mode = self.effective_collaboration_mode(); + let turn_context = self.current_turn_context_snapshot(); let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask .as_ref() @@ -5096,6 +5099,7 @@ impl ChatWidget { text_elements, local_image_paths, remote_image_urls, + Some(turn_context), )); } else if render_in_history && !remote_image_urls.is_empty() { self.last_rendered_user_message_event = @@ -5110,6 +5114,7 @@ impl ChatWidget { Vec::new(), Vec::new(), remote_image_urls, + Some(turn_context), )); } @@ -5395,6 +5400,7 @@ impl ChatWidget { num_turns: rollback.num_turns, }); } + self.emit_thread_rollback_context_diff(&rollback); } EventMsg::RawResponseItem(_) | EventMsg::ItemStarted(_) @@ -5543,6 +5549,7 @@ impl ChatWidget { event.text_elements, event.local_images, remote_image_urls, + None, )); } @@ -8082,6 +8089,147 @@ impl ChatWidget { self.effective_reasoning_effort() } + fn permissions_label( + &self, + approval_policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + approvals_reviewer: ApprovalsReviewer, + ) -> String { + builtin_approval_presets() + .into_iter() + .find(|preset| Self::preset_matches_current(approval_policy, sandbox_policy, preset)) + .map(|preset| { + if preset.id == "auto" && approvals_reviewer == ApprovalsReviewer::GuardianSubagent + { + "Smart Approvals".to_string() + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ) { + return "Default (non-admin sandbox)".to_string(); + } + } + "Default".to_string() + } else { + preset.label.to_string() + } + }) + .unwrap_or_else(|| { + let approval = approval_policy.to_string(); + let sandbox = match sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly { .. } => "read-only".to_string(), + SandboxPolicy::WorkspaceWrite { + network_access: true, + .. + } => "workspace-write with network access".to_string(), + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + SandboxPolicy::ExternalSandbox { network_access } => { + if network_access.is_enabled() { + "external-sandbox (network access enabled)".to_string() + } else { + "external-sandbox".to_string() + } + } + }; + format!("Custom ({sandbox}, {approval})") + }) + } + + fn model_context_label( + &self, + model: &str, + effort: Option, + service_tier: Option, + ) -> String { + let label = Self::status_line_reasoning_effort_label(effort); + let fast_label = if self.should_show_fast_status(model, service_tier) { + " fast" + } else { + "" + }; + format!("{model} {label}{fast_label}") + } + + fn historical_turn_context_snapshot( + &self, + turn_context: &TurnContextItem, + ) -> TurnContextSnapshot { + TurnContextSnapshot { + permissions: Self::permissions_label( + self, + turn_context.approval_policy, + &turn_context.sandbox_policy, + turn_context.approvals_reviewer, + ), + mode: turn_context + .collaboration_mode + .as_ref() + .map(|mode| mode.mode.display_name()) + .unwrap_or(ModeKind::Default.display_name()) + .to_string(), + model: self.model_context_label( + &turn_context.model, + turn_context.effort, + turn_context.service_tier, + ), + personality: turn_context + .personality + .map(Self::personality_label) + .unwrap_or("Default") + .to_string(), + cwd: format_directory_display(&turn_context.cwd, None), + } + } + + fn emit_thread_rollback_context_diff( + &self, + rollback: &codex_protocol::protocol::ThreadRolledBackEvent, + ) { + let Some(turn_context) = rollback.rolled_back_to_turn_context.as_ref() else { + return; + }; + let historical_snapshot = self.historical_turn_context_snapshot(turn_context); + let current_snapshot = self.current_turn_context_snapshot(); + for message in historical_snapshot.rollback_diff_messages(¤t_snapshot) { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + } + } + + pub(crate) fn current_turn_context_snapshot(&self) -> TurnContextSnapshot { + let permissions = self.permissions_label( + self.config.permissions.approval_policy.value(), + self.config.permissions.sandbox_policy.get(), + self.config.approvals_reviewer, + ); + let model = self.model_context_label( + self.model_display_name(), + self.effective_reasoning_effort(), + self.config.service_tier, + ); + let personality = self + .config + .personality + .filter(|_| self.config.features.enabled(Feature::Personality)) + .filter(|_| self.current_model_supports_personality()) + .map(Self::personality_label) + .unwrap_or("Default") + .to_string(); + + TurnContextSnapshot { + permissions, + mode: self.active_mode_kind().display_name().to_string(), + model, + personality, + cwd: format_directory_display(&self.config.cwd, None), + } + } + #[cfg(test)] pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { self.active_mode_kind() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_thread_rollback_turn_context_diff_info_messages.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_thread_rollback_turn_context_diff_info_messages.snap new file mode 100644 index 0000000000..5d4743cbae --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__replayed_thread_rollback_turn_context_diff_info_messages.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 3146 +expression: rendered +--- +• Permissions updated to Smart Approvals + +• Model changed to gpt-5.4 high for Plan mode. + +• Personality set to Pragmatic + +• Directory changed to /tmp/current diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffc571288a..47345a5df5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -107,6 +107,7 @@ use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnCompleteEvent; +use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UndoCompletedEvent; use codex_protocol::protocol::UndoStartedEvent; @@ -3053,6 +3054,7 @@ async fn replayed_thread_rollback_emits_ordered_app_event() { chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 2, + rolled_back_to_turn_context: None, })]); let mut saw = false; @@ -3067,6 +3069,88 @@ async fn replayed_thread_rollback_emits_ordered_app_event() { assert!(saw, "expected replay rollback app event"); } +#[tokio::test] +async fn replayed_thread_rollback_turn_context_diff_emits_ordered_info_messages() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + chat.set_feature_enabled(Feature::Personality, true); + chat.set_approval_policy(AskForApproval::OnRequest); + chat.set_sandbox_policy(SandboxPolicy::new_workspace_write_policy()) + .expect("workspace-write sandbox"); + chat.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent); + chat.set_service_tier(Some(ServiceTier::Fast)); + chat.set_personality(Personality::Pragmatic); + chat.config.cwd = PathBuf::from("/tmp/current"); + chat.set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-5.4".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::High)), + developer_instructions: None, + }); + let _ = drain_insert_history(&mut rx); + + chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: Some(TurnContextItem { + turn_id: Some("turn-1".to_string()), + trace_id: None, + cwd: PathBuf::from("/tmp/historical"), + current_date: None, + timezone: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + network: None, + model: "gpt-5.3-codex".to_string(), + service_tier: None, + personality: None, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.3-codex".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Medium), + developer_instructions: None, + }, + }), + realtime_active: Some(false), + effort: Some(ReasoningEffortConfig::Medium), + summary: codex_protocol::config_types::ReasoningSummary::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }), + })]); + + let mut saw_rollback = false; + let mut rendered_cells = Vec::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::ApplyThreadRollback { num_turns } => { + assert!(!saw_rollback, "expected only one rollback app event"); + saw_rollback = true; + assert_eq!(num_turns, 1); + } + AppEvent::InsertHistoryCell(cell) => { + assert!( + saw_rollback, + "rollback trim should be queued before info cells" + ); + rendered_cells.push(lines_to_single_string(&cell.display_lines(80))); + } + _ => {} + } + } + + assert!(saw_rollback, "expected replay rollback app event"); + let rendered = rendered_cells.join("\n"); + assert_snapshot!( + "replayed_thread_rollback_turn_context_diff_info_messages", + rendered + ); +} + #[tokio::test] async fn plan_implementation_popup_skips_when_messages_queued() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; @@ -5918,7 +6002,10 @@ async fn slash_copy_state_clears_on_thread_rollback() { }); chat.handle_codex_event(Event { id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); assert_eq!(chat.last_copyable_output, None); @@ -6002,7 +6089,10 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { chat.handle_codex_event(Event { id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 70502659f6..479969b31c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -30,6 +30,7 @@ use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; +use crate::turn_context::TurnContextSnapshot; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; @@ -203,6 +204,7 @@ pub(crate) struct UserHistoryCell { #[allow(dead_code)] pub local_image_paths: Vec, pub remote_image_urls: Vec, + pub turn_context: Option, } /// Build logical lines for a user message with styled text elements. @@ -1208,12 +1210,14 @@ pub(crate) fn new_user_prompt( text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, + turn_context: Option, ) -> UserHistoryCell { UserHistoryCell { message, text_elements, local_image_paths, remote_image_urls, + turn_context, } } @@ -3841,6 +3845,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. @@ -3858,6 +3863,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: vec!["https://example.com/example.png".to_string()], + turn_context: None, }; let rendered = render_lines(&cell.display_lines(80)).join("\n"); @@ -3874,6 +3880,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: vec!["data:image/png;base64,aGVsbG8=".to_string()], + turn_context: None, }; let rendered = render_lines(&cell.display_lines(80)).join("\n"); @@ -3892,6 +3899,7 @@ mod tests { "https://example.com/one.png".to_string(), "https://example.com/two.png".to_string(), ], + turn_context: None, }; let rendered = render_lines(&cell.display_lines(80)).join("\n"); @@ -3911,6 +3919,7 @@ mod tests { "https://example.com/one.png".to_string(), "https://example.com/two.png".to_string(), ], + turn_context: None, }; let width = 80; @@ -3930,6 +3939,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: vec!["https://example.com/one.png".to_string()], + turn_context: None, }; let rendered = render_lines(&cell.display_lines(80)); @@ -3953,6 +3963,7 @@ mod tests { )], local_image_paths: Vec::new(), remote_image_urls: vec!["https://example.com/one.png".to_string()], + turn_context: None, }; let rendered = render_lines(&cell.display_lines(80)); @@ -3973,6 +3984,7 @@ mod tests { text_elements: Vec::new(), local_image_paths: Vec::new(), remote_image_urls: Vec::new(), + turn_context: None, }); let width: u16 = 52; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7bc575c3e9..e424f52c94 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -123,6 +123,7 @@ mod text_formatting; mod theme_picker; mod tooltips; mod tui; +mod turn_context; mod ui_consts; pub mod update_action; mod update_prompt; @@ -1427,9 +1428,11 @@ mod tests { current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: config.approvals_reviewer, sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, model, + service_tier: config.service_tier, personality: None, collaboration_mode: None, realtime_active: Some(false), diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 93a7148bce..c81fc6a98e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -24,6 +24,7 @@ use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; @@ -429,6 +430,7 @@ pub(crate) struct TranscriptOverlay { /// Committed transcript cells (does not include the live tail). cells: Vec>, highlight_cell: Option, + highlight_context_lines: Option>>, /// Cache key for the render-only live tail appended after committed cells. live_tail_key: Option, is_done: bool, @@ -457,12 +459,13 @@ impl TranscriptOverlay { pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( - Self::render_cells(&transcript_cells, None), + Self::render_cells(&transcript_cells, None, None), "T R A N S C R I P T".to_string(), usize::MAX, ), cells: transcript_cells, highlight_cell: None, + highlight_context_lines: None, live_tail_key: None, is_done: false, } @@ -471,6 +474,7 @@ impl TranscriptOverlay { fn render_cells( cells: &[Arc], highlight_cell: Option, + highlight_context_lines: Option<&[Line<'static>]>, ) -> Vec> { cells .iter() @@ -492,6 +496,15 @@ impl TranscriptOverlay { style: Style::default(), })) as Box }; + if highlight_cell == Some(i) + && let Some(lines) = highlight_context_lines + && !lines.is_empty() + { + let summary = Box::new(CachedRenderable::new( + Paragraph::new(Text::from(lines.to_vec())).wrap(Wrap { trim: false }), + )) as Box; + cell_renderable = Box::new(ColumnRenderable::with([summary, cell_renderable])); + } if !c.is_stream_continuation() && i > 0 { cell_renderable = Box::new(InsetRenderable::new( cell_renderable, @@ -519,7 +532,11 @@ impl TranscriptOverlay { let had_prior_cells = !self.cells.is_empty(); let tail_renderable = self.take_live_tail_renderable(); self.cells.push(cell); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = Self::render_cells( + &self.cells, + self.highlight_cell, + self.highlight_context_lines.as_deref(), + ); if let Some(tail) = tail_renderable { let tail = if !had_prior_cells && self @@ -553,6 +570,7 @@ impl TranscriptOverlay { .is_some_and(|idx| idx >= self.cells.len()) { self.highlight_cell = None; + self.highlight_context_lines = None; } self.rebuild_renderables(); if follow_bottom { @@ -608,8 +626,13 @@ impl TranscriptOverlay { } } - pub(crate) fn set_highlight_cell(&mut self, cell: Option) { + pub(crate) fn set_highlight_cell( + &mut self, + cell: Option, + context_lines: Option>>, + ) { self.highlight_cell = cell; + self.highlight_context_lines = context_lines; self.rebuild_renderables(); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); @@ -626,7 +649,11 @@ impl TranscriptOverlay { fn rebuild_renderables(&mut self) { let tail_renderable = self.take_live_tail_renderable(); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = Self::render_cells( + &self.cells, + self.highlight_cell, + self.highlight_context_lines.as_deref(), + ); if let Some(tail) = tail_renderable { self.view.renderables.push(tail); } @@ -809,7 +836,9 @@ mod tests { use crate::exec_cell::CommandOutput; use crate::history_cell; use crate::history_cell::HistoryCell; + use crate::history_cell::UserHistoryCell; use crate::history_cell::new_patch_event; + use crate::turn_context::TurnContextSnapshot; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::FileChange; use ratatui::Terminal; @@ -863,7 +892,7 @@ mod tests { let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { lines: vec![Line::from("hello")], })]); - overlay.set_highlight_cell(Some(0)); + overlay.set_highlight_cell(Some(0), None); // Render into a wide buffer so the footer hints aren't truncated. let area = Rect::new(0, 0, 120, 10); @@ -897,6 +926,37 @@ mod tests { assert_snapshot!(term.backend()); } + #[test] + fn transcript_overlay_highlighted_turn_context_snapshot() { + let selected = TurnContextSnapshot { + permissions: "Default".to_string(), + mode: "Default".to_string(), + model: "gpt-5.4 high".to_string(), + personality: "Default".to_string(), + cwd: "~/code/old".to_string(), + }; + let current = TurnContextSnapshot { + permissions: "Smart Approvals".to_string(), + mode: "Plan".to_string(), + model: "gpt-5.4 xhigh fast".to_string(), + personality: "Pragmatic".to_string(), + cwd: "~/code/new".to_string(), + }; + let mut overlay = TranscriptOverlay::new(vec![Arc::new(UserHistoryCell { + message: "Implement the highlighted task".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + turn_context: Some(selected.clone()), + })]); + overlay.set_highlight_cell(Some(0), selected.diff_lines(¤t)); + + let mut term = Terminal::new(TestBackend::new(72, 16)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } + #[test] fn transcript_overlay_renders_live_tail() { let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_highlighted_turn_context_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_highlighted_turn_context_snapshot.snap new file mode 100644 index 0000000000..18d7f47a62 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_highlighted_turn_context_snapshot.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / " +" Context changed since this turn: " +" permissions: Default -> Smart Approvals " +" mode: Default -> Plan " +" model: gpt-5.4 high -> gpt-5.4 xhigh fast " +" personality: Default -> Pragmatic " +" directory: ~/code/old -> ~/code/new " +" " +" " +"› Implement the highlighted task " +" " +"~ " +"───────────────────────────────────────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page home/end to jump " +" q to quit esc/← to edit prev → to edit next enter to edit message" +" " diff --git a/codex-rs/tui/src/turn_context.rs b/codex-rs/tui/src/turn_context.rs new file mode 100644 index 0000000000..1d163ba8dd --- /dev/null +++ b/codex-rs/tui/src/turn_context.rs @@ -0,0 +1,171 @@ +use ratatui::style::Stylize; +use ratatui::text::Line; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TurnContextSnapshot { + pub permissions: String, + pub mode: String, + pub model: String, + pub personality: String, + pub cwd: String, +} + +impl TurnContextSnapshot { + pub(crate) fn diff_lines(&self, current: &Self) -> Option>> { + let mut diffs: Vec<(&'static str, &str, &str)> = Vec::new(); + + if self.permissions != current.permissions { + diffs.push(("permissions", &self.permissions, ¤t.permissions)); + } + if self.mode != current.mode { + diffs.push(("mode", &self.mode, ¤t.mode)); + } + if self.model != current.model { + diffs.push(("model", &self.model, ¤t.model)); + } + if self.personality != current.personality { + diffs.push(("personality", &self.personality, ¤t.personality)); + } + if self.cwd != current.cwd { + diffs.push(("directory", &self.cwd, ¤t.cwd)); + } + + if diffs.is_empty() { + return None; + } + + let mut lines = vec![vec![" ".into(), "Context changed since this turn:".bold()].into()]; + + for (label, then_value, now_value) in diffs { + lines.push( + vec![ + " ".into(), + format!("{label}: ").dim(), + then_value.to_string().dim(), + " -> ".dim(), + now_value.to_string().cyan(), + ] + .into(), + ); + } + lines.push("".into()); + + Some(lines) + } + + pub(crate) fn rollback_diff_messages(&self, current: &Self) -> Vec { + let mut messages = Vec::new(); + + if self.permissions != current.permissions { + messages.push(format!("Permissions updated to {}", current.permissions)); + } + + let mode_changed = self.mode != current.mode; + let model_changed = self.model != current.model; + if model_changed { + let mut message = format!("Model changed to {}", current.model); + if mode_changed { + message.push_str(" for "); + message.push_str(¤t.mode); + message.push_str(" mode."); + } + messages.push(message); + } else if mode_changed { + messages.push(format!("Switched to {} mode.", current.mode)); + } + + if self.personality != current.personality { + messages.push(format!("Personality set to {}", current.personality)); + } + + if self.cwd != current.cwd { + messages.push(format!("Directory changed to {}", current.cwd)); + } + + messages + } +} + +#[cfg(test)] +mod tests { + use super::TurnContextSnapshot; + use pretty_assertions::assert_eq; + + fn render_lines(lines: &[ratatui::text::Line<'static>]) -> Vec { + lines.iter().map(ToString::to_string).collect() + } + + #[test] + fn diff_lines_returns_none_when_context_matches() { + let snapshot = TurnContextSnapshot { + permissions: "Default".to_string(), + mode: "Default".to_string(), + model: "gpt-5.4 xhigh fast".to_string(), + personality: "Default".to_string(), + cwd: "~/code/codex".to_string(), + }; + + assert_eq!(snapshot.diff_lines(&snapshot), None); + } + + #[test] + fn diff_lines_lists_changed_fields_in_stable_order() { + let then = TurnContextSnapshot { + permissions: "Default".to_string(), + mode: "Default".to_string(), + model: "gpt-5.4 high".to_string(), + personality: "Default".to_string(), + cwd: "~/code/old".to_string(), + }; + let now = TurnContextSnapshot { + permissions: "Smart Approvals".to_string(), + mode: "Plan".to_string(), + model: "gpt-5.4 xhigh fast".to_string(), + personality: "Pragmatic".to_string(), + cwd: "~/code/new".to_string(), + }; + + let lines = then.diff_lines(&now).expect("expected diff lines"); + + assert_eq!( + render_lines(&lines), + vec![ + " Context changed since this turn:".to_string(), + " permissions: Default -> Smart Approvals".to_string(), + " mode: Default -> Plan".to_string(), + " model: gpt-5.4 high -> gpt-5.4 xhigh fast".to_string(), + " personality: Default -> Pragmatic".to_string(), + " directory: ~/code/old -> ~/code/new".to_string(), + String::new(), + ] + ); + } + + #[test] + fn rollback_diff_messages_use_existing_tui_phrasing() { + let then = TurnContextSnapshot { + permissions: "Default".to_string(), + mode: "Default".to_string(), + model: "gpt-5.4 high".to_string(), + personality: "Default".to_string(), + cwd: "~/code/old".to_string(), + }; + let now = TurnContextSnapshot { + permissions: "Smart Approvals".to_string(), + mode: "Plan".to_string(), + model: "gpt-5.4 xhigh fast".to_string(), + personality: "Pragmatic".to_string(), + cwd: "~/code/new".to_string(), + }; + + assert_eq!( + then.rollback_diff_messages(&now), + vec![ + "Permissions updated to Smart Approvals".to_string(), + "Model changed to gpt-5.4 xhigh fast for Plan mode.".to_string(), + "Personality set to Pragmatic".to_string(), + "Directory changed to ~/code/new".to_string(), + ] + ); + } +} diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index b99d46e0d5..b3be840b94 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -7490,7 +7490,10 @@ guardian_approval = true local_images: Vec::new(), text_elements: Vec::new(), }), - EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), EventMsg::UserMessage(UserMessageEvent { message: "third prompt".to_string(), images: None, @@ -7580,7 +7583,10 @@ guardian_approval = true // Simulate a live rollback arriving before queued replay inserts are drained. app.handle_codex_event_now(Event { id: "live-rollback".to_string(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); let mut saw_rollback = false; diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 07770182b2..ab6d4175ce 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -3030,6 +3030,7 @@ async fn replayed_thread_rollback_emits_ordered_app_event() { chat.replay_initial_messages(vec![EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 2, + rolled_back_to_turn_context: None, })]); let mut saw = false; @@ -5865,7 +5866,10 @@ async fn slash_copy_state_clears_on_thread_rollback() { }); chat.handle_codex_event(Event { id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); assert_eq!(chat.last_copyable_output, None); @@ -5949,7 +5953,10 @@ async fn slash_copy_does_not_return_stale_output_after_thread_rollback() { chat.handle_codex_event(Event { id: "rollback-1".into(), - msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), + msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { + num_turns: 1, + rolled_back_to_turn_context: None, + }), }); let _ = drain_insert_history(&mut rx); diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 9ab25aea17..aae4292ef1 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -1787,9 +1787,11 @@ mod tests { current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), + approvals_reviewer: config.approvals_reviewer, sandbox_policy: config.permissions.sandbox_policy.get().clone(), network: None, model, + service_tier: config.service_tier, personality: None, collaboration_mode: None, realtime_active: Some(false), diff --git a/codex-rs/tui_app_server/src/multi_agents.rs b/codex-rs/tui_app_server/src/multi_agents.rs index 00a1c0429c..8091204884 100644 --- a/codex-rs/tui_app_server/src/multi_agents.rs +++ b/codex-rs/tui_app_server/src/multi_agents.rs @@ -541,6 +541,7 @@ fn status_summary_spans(status: &AgentStatus) -> Vec> { match status { AgentStatus::PendingInit => vec![Span::from("Pending init").cyan()], AgentStatus::Running => vec![Span::from("Running").cyan().bold()], + AgentStatus::Interrupted => vec![Span::from("Interrupted").magenta()], AgentStatus::Completed(message) => { let mut spans = vec![Span::from("Completed").green()]; if let Some(message) = message.as_ref() {