tui: show rollback turn-context diffs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-16 11:22:52 -07:00
co-authored by Codex
parent af3e7742f4
commit 9803d01715
23 changed files with 824 additions and 33 deletions
@@ -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
+34 -8
View File
@@ -199,6 +199,12 @@ pub(crate) struct PreviousTurnSettings {
pub(crate) realtime_active: Option<bool>,
}
#[derive(Clone, Debug, Default)]
struct AppliedRolloutReconstruction {
previous_turn_settings: Option<PreviousTurnSettings>,
surviving_turn_context_item: Option<TurnContextItem>,
}
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<TokenUsageInfo> {
@@ -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::<Vec<_>>();
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(),
@@ -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),
+129 -1
View File
@@ -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]
@@ -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,
@@ -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")),
@@ -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,
+24
View File
@@ -2457,10 +2457,15 @@ pub struct TurnContextItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
pub approval_policy: AskForApproval,
#[serde(default)]
pub approvals_reviewer: ApprovalsReviewer,
pub sandbox_policy: SandboxPolicy,
#[serde(skip_serializing_if = "Option::is_none")]
pub network: Option<TurnContextNetworkItem>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub personality: Option<Personality>,
#[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<TurnContextItem>,
}
#[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]
+4
View File
@@ -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,
+15 -2
View File
@@ -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<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
@@ -6981,6 +6982,7 @@ guardian_approval = true
text_elements,
local_image_paths,
remote_image_urls,
turn_context: None,
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
@@ -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<dyn HistoryCell>];
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<dyn HistoryCell>];
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<dyn HistoryCell>,
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<dyn HistoryCell>,
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<dyn HistoryCell>];
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
app.deferred_history_lines = vec![Line::from("stale buffered line")];
+17 -2
View File
@@ -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::<UserHistoryCell>())
.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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
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<dyn HistoryCell>,
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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
+148
View File
@@ -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<ReasoningEffortConfig>,
service_tier: Option<ServiceTier>,
) -> 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(&current_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()
@@ -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
+92 -2
View File
@@ -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);
+12
View File
@@ -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<PathBuf>,
pub remote_image_urls: Vec<String>,
pub turn_context: Option<TurnContextSnapshot>,
}
/// Build logical lines for a user message with styled text elements.
@@ -1208,12 +1210,14 @@ pub(crate) fn new_user_prompt(
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>,
turn_context: Option<TurnContextSnapshot>,
) -> 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;
+3
View File
@@ -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),
+65 -5
View File
@@ -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<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
highlight_context_lines: Option<Vec<Line<'static>>>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
is_done: bool,
@@ -457,12 +459,13 @@ impl TranscriptOverlay {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> 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<dyn HistoryCell>],
highlight_cell: Option<usize>,
highlight_context_lines: Option<&[Line<'static>]>,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
@@ -492,6 +496,15 @@ impl TranscriptOverlay {
style: Style::default(),
})) as Box<dyn Renderable>
};
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<dyn Renderable>;
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<usize>) {
pub(crate) fn set_highlight_cell(
&mut self,
cell: Option<usize>,
context_lines: Option<Vec<Line<'static>>>,
) {
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(&current));
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 {
@@ -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"
" "
+171
View File
@@ -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<Vec<Line<'static>>> {
let mut diffs: Vec<(&'static str, &str, &str)> = Vec::new();
if self.permissions != current.permissions {
diffs.push(("permissions", &self.permissions, &current.permissions));
}
if self.mode != current.mode {
diffs.push(("mode", &self.mode, &current.mode));
}
if self.model != current.model {
diffs.push(("model", &self.model, &current.model));
}
if self.personality != current.personality {
diffs.push(("personality", &self.personality, &current.personality));
}
if self.cwd != current.cwd {
diffs.push(("directory", &self.cwd, &current.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<String> {
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(&current.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<String> {
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(),
]
);
}
}
+8 -2
View File
@@ -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;
@@ -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);
+2
View File
@@ -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),
@@ -541,6 +541,7 @@ fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
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() {