Show streaming reasoning summaries in the TUI status row (#43921)

## What changed

- Update the status heading from the latest usable reasoning line and retain it through tool activity and empty reasoning items. Keep completed reasoning in the expanded transcript only.
- Restore active reasoning after resume, thread switches, and session refreshes, including when earlier events have left the replay buffer.
- Default new embedded TUI threads to `model_reasoning_summary = "detailed"`, honoring explicit summary settings. Keep `concurrent_reasoning_summaries` opt-in and disable it when summaries are `none`.
- Persist the request's captured reasoning summary setting in context updates and local and remote compaction checkpoints.
- Animate status text with a smooth, grapheme-aware shimmer that respects terminal colors and reduced motion.

## Testing

Add regression coverage for reasoning status updates, transcript visibility, resume and buffered replay, outbound summary configuration, and summary settings across all three compaction modes. Add shimmer snapshots and checks for smooth brightness changes and Unicode grapheme preservation.

GitOrigin-RevId: 725c7de20f23166d7a3da6daeca617add5cbc600
This commit is contained in:
Felipe Coury
2026-09-08 21:41:02 +00:00
committed by copyberry
parent 78932f4493
commit 9ec33e1926
45 changed files with 1798 additions and 90 deletions
+2 -2
View File
@@ -381,8 +381,8 @@ async fn run_compact_task_inner_impl(
}
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage { .. } => {
Some(turn_context.to_turn_context_item())
InitialContextInjection::BeforeLastUserMessage { step_context, .. } => {
Some(step_context.to_turn_context_item())
}
};
sess.replace_compacted_history(
+2 -2
View File
@@ -273,8 +273,8 @@ async fn run_remote_compact_task_inner_impl(
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage { .. } => {
Some(compaction_turn_context.to_turn_context_item())
InitialContextInjection::BeforeLastUserMessage { step_context, .. } => {
Some(step_context.to_turn_context_item())
}
};
// Install is the semantic boundary where the compact endpoint's output becomes live
+2 -2
View File
@@ -329,8 +329,8 @@ async fn run_remote_compact_task_inner_impl(
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage { .. } => {
Some(compaction_turn_context.to_turn_context_item())
InitialContextInjection::BeforeLastUserMessage { step_context, .. } => {
Some(step_context.to_turn_context_item())
}
};
if let Some(trace_input_history) = trace_input_history.as_deref() {
+2 -2
View File
@@ -4297,7 +4297,7 @@ impl Session {
.map(ResponseItemEnvelope::new)
.chain(retained_client_developer_messages)
.collect();
let turn_context_item = turn_context.to_turn_context_item();
let turn_context_item = step_context.to_turn_context_item();
self.replace_compacted_history(
context_items,
Some(turn_context_item),
@@ -4343,7 +4343,7 @@ impl Session {
let state = self.state.lock().await;
state.reference_context_item()
};
let turn_context_item = turn_context.to_turn_context_item();
let turn_context_item = step_context.to_turn_context_item();
let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item);
let should_inject_full_context = reference_context_item.is_none();
let world_state = Arc::new(self.build_world_state_for_step(step_context).await?);
@@ -433,6 +433,27 @@ async fn submitted_sparse_updates_preserve_captured_steps_and_ordering() {
]
);
assert!(Arc::ptr_eq(&before.turn, &after.turn));
assert_eq!(
turn.to_turn_context_item().summary,
ReasoningSummary::Concise
);
// Even after the live update, an already-captured request retains its own
// summary setting in the durable context.
for step in [&before, &during, &after] {
let world_state = session
.record_context_updates_and_set_reference_context_item(step)
.await
.expect("record captured settings");
assert_eq!(
session.reference_context_item().await.unwrap().summary,
step.settings.reasoning_summary,
);
session.start_new_context_window(step, world_state).await;
assert_eq!(
session.reference_context_item().await.unwrap().summary,
step.settings.reasoning_summary,
);
}
assert_eq!(after.settings.model_info.as_ref(), &expected_destination);
let initial_budget = turn
.config
+12
View File
@@ -1,3 +1,5 @@
//! Request-scoped settings and capabilities, including the durable context snapshot.
use std::sync::Arc;
use crate::agents_md::LoadedAgentsMd;
@@ -10,6 +12,7 @@ use codex_exec_server::ExecutorCapabilityDiscoverySnapshot;
use codex_exec_server::ResolvedSelectedCapabilityRoot;
use codex_mcp::McpBinding;
use codex_otel::SessionTelemetry;
use codex_protocol::protocol::TurnContextItem;
/// Request-scoped state that may change between model sampling requests.
pub(crate) struct StepContext {
@@ -32,3 +35,12 @@ pub(crate) struct StepContext {
/// The canonical AGENTS.md value observed with this environment snapshot.
pub(crate) loaded_agents_md: Option<Arc<LoadedAgentsMd>>,
}
impl StepContext {
/// Persist the summary captured for this request, even after a live settings update.
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
let mut item = self.turn.to_turn_context_item();
item.summary = self.settings.reasoning_summary;
item
}
}
+1 -1
View File
@@ -639,7 +639,7 @@ impl TurnContext {
realtime_active: Some(self.realtime_active),
cyber_access_program: self.cyber_access_program,
effort: self.reasoning_effort().cloned(),
summary: ReasoningSummaryConfig::Auto,
summary: self.reasoning_summary(),
}
}
@@ -1481,3 +1481,6 @@ async fn request_preference_activation_keeps_admitted_model_metadata() -> Result
Ok(())
}
#[path = "step_settings_compaction.rs"]
mod compaction;
@@ -0,0 +1,129 @@
//! Compaction checkpoints retain the summary captured after a mid-turn settings update.
use super::*;
use codex_history::RolloutItem;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use test_case::test_case;
#[derive(Clone, Copy)]
enum CompactionMode {
Local,
Remote,
RemoteV2,
}
#[test_case(CompactionMode::Local; "local")]
#[test_case(CompactionMode::Remote; "remote")]
#[test_case(CompactionMode::RemoteV2; "remote v2")]
#[tokio::test]
async fn compaction_preserves_updated_summary(mode: CompactionMode) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut bodies = vec![
paused_response("pause", "pause-call"),
sse(vec![
ev_function_call(
"plan",
"update_plan",
&json!({"plan": [{"step": "Finish the answer", "status": "in_progress"}]})
.to_string(),
),
responses::ev_completed_with_tokens("plan-response", /*total_tokens*/ 330_000),
]),
];
let compact = match mode {
CompactionMode::Local => {
bodies.push(sse(vec![
responses::ev_assistant_message("summary", "Compacted history"),
ev_completed("compact"),
]));
None
}
CompactionMode::Remote => Some(
responses::mount_compact_user_history_with_summary_once(&server, "encrypted-summary")
.await,
),
CompactionMode::RemoteV2 => {
bodies.push(sse(vec![json!({"type": "response.output_item.done", "item": {"type": "compaction", "encrypted_content": "encrypted-summary"}}), ev_completed("compact")]));
None
}
};
bodies.push(sse_completed("done"));
let responses = mount_sse_sequence(&server, bodies).await;
let test = step_settings_test()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
config.model_auto_compact_token_limit = Some(200_000);
config
.features
.disable(Feature::TokenBudget)
.expect("disable token budget");
config.model_provider.name = match mode {
CompactionMode::Local => "Local compaction test",
CompactionMode::Remote | CompactionMode::RemoteV2 => "OpenAI",
}
.to_string();
if matches!(mode, CompactionMode::RemoteV2) {
config
.features
.enable(Feature::RemoteCompactionV2)
.expect("enable v2");
} else {
config
.features
.disable(Feature::RemoteCompactionV2)
.expect("disable v2");
}
})
.build_with_auto_env(&server)
.await?;
let pause = start_paused_turn(&test.codex).await?;
assert_eq!(
submit_turn_settings(
&test.codex,
&pause.turn_id,
TurnSettingsUpdate {
summary: Some(ReasoningSummary::Detailed),
..Default::default()
}
)
.await?,
TurnSettingsUpdateOutcome::Applied
);
answer_paused_turn(&test.codex, &pause.turn_id).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.codex.shutdown_and_wait().await?;
let requests = responses.requests();
assert_eq!(
[
requests[0].body_json()["reasoning"]["summary"].clone(),
requests[1].body_json()["reasoning"]["summary"].clone()
],
[json!("concise"), json!("detailed")]
);
if let Some(compact) = compact {
compact.single_request();
}
let rollout =
std::fs::read_to_string(test.session_configured.rollout_path.expect("rollout path"))?;
let items = rollout
.lines()
.map(codex_rollout::parse_rollout_line)
.collect::<std::result::Result<Vec<_>, _>>()?;
let checkpoint = items
.iter()
.skip_while(|line| !matches!(line.item, RolloutItem::Compacted(_)))
.skip(/*n*/ 1)
.take_while(|line| !matches!(line.item, RolloutItem::EventMsg(_)))
.find_map(|line| match &line.item {
RolloutItem::TurnContext(context) => Some(context.summary),
_ => None,
});
assert_eq!(checkpoint, Some(ReasoningSummary::Detailed));
Ok(())
}
+1
View File
@@ -236,6 +236,7 @@ mod platform_actions;
mod plugin_mentions;
mod rate_limit_refresh;
mod realtime_delivery;
mod reasoning_replay;
mod recap;
mod reconnect;
mod replay_filter;
+67
View File
@@ -0,0 +1,67 @@
//! Restores the active reasoning stream without treating its snapshot as a completed item.
//!
//! Buffered turn events must replay before the active item is restored. Its first retained
//! event, or the end of replay when all its events were evicted, establishes that boundary.
use super::ThreadBufferedEvent;
use super::ThreadEventSnapshot;
use crate::chatwidget::ChatWidget;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::TurnStatus;
pub(super) struct ReasoningReplay {
started: Option<ItemStartedNotification>,
parts: Option<(Vec<String>, Vec<String>)>,
}
impl ReasoningReplay {
pub(super) fn new(snapshot: &mut ThreadEventSnapshot) -> Self {
let started = snapshot.active_reasoning_item.take();
let parts = started.as_ref().and_then(|started| {
let turn = snapshot
.turns
.iter_mut()
.find(|turn| turn.id == started.turn_id && turn.status == TurnStatus::InProgress)?;
let index = turn.items.iter().position(
|item| matches!(item, ThreadItem::Reasoning { id, .. } if id == started.item.id()),
)?;
match turn.items.remove(index) {
ThreadItem::Reasoning {
summary, content, ..
} => Some((summary, content)),
_ => unreachable!("matched a reasoning item"),
}
});
Self { started, parts }
}
pub(super) fn before_event(&mut self, event: &ThreadBufferedEvent, chat: &mut ChatWidget) {
if self.started.as_ref().is_some_and(|started| {
matches!(event, ThreadBufferedEvent::Notification(notification) if match notification.as_ref() {
ServerNotification::ItemStarted(item) => {
item.turn_id == started.turn_id && item.item.id() == started.item.id()
}
ServerNotification::ReasoningSummaryTextDelta(delta) => {
delta.turn_id == started.turn_id && delta.item_id == started.item.id()
}
ServerNotification::ReasoningTextDelta(delta) => {
delta.turn_id == started.turn_id && delta.item_id == started.item.id()
}
ServerNotification::ReasoningSummaryPartAdded(part) => {
part.turn_id == started.turn_id && part.item_id == started.item.id()
}
_ => false,
})
}) {
self.restore(chat);
}
}
pub(super) fn restore(&mut self, chat: &mut ChatWidget) {
if let Some(started) = self.started.take() {
chat.restore_active_reasoning_item(started, self.parts.take());
}
}
}
+19
View File
@@ -48,6 +48,8 @@ mod realtime_handoff_e2e;
mod realtime_requests;
#[path = "tests/realtime_start.rs"]
mod realtime_start;
#[path = "tests/reasoning_resume_tests.rs"]
mod reasoning_resume_tests;
#[path = "tests/recap_generation_tests.rs"]
mod recap_generation;
mod safety_buffering;
@@ -1348,6 +1350,7 @@ async fn replayed_turn_complete_submits_restored_queued_follow_up() {
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
))],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1402,6 +1405,7 @@ async fn replay_only_thread_keeps_restored_queue_visible() {
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Completed),
))],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ false,
@@ -1452,6 +1456,7 @@ async fn replay_thread_snapshot_keeps_queue_when_running_state_only_comes_from_s
session: None,
turns: Vec::new(),
events: vec![],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1502,6 +1507,7 @@ async fn replay_thread_snapshot_in_progress_turn_restores_running_queue_state()
session: None,
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1532,6 +1538,7 @@ async fn replay_thread_snapshot_in_progress_turn_restores_running_state_without_
session: None,
turns: vec![test_turn("turn-1", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -1584,6 +1591,7 @@ async fn replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up()
thread_id, "turn-1",
))),
],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1717,6 +1725,7 @@ async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
session: None,
turns: Vec::new(),
events: vec![],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1798,6 +1807,7 @@ async fn replay_thread_snapshot_restores_collaboration_mode_without_input() {
session: None,
turns: Vec::new(),
events: vec![],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -1851,6 +1861,7 @@ async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
events: vec![ThreadBufferedEvent::Notification(Box::new(
turn_completed_notification(thread_id, "turn-1", TurnStatus::Interrupted),
))],
active_reasoning_item: None,
input_state: Some(input_state),
},
/*resume_restored_queue*/ true,
@@ -3748,6 +3759,7 @@ async fn replay_snapshot_with_pending_request_suppresses_replay_notices() {
/*approval_id*/ None,
))),
],
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -4142,6 +4154,7 @@ async fn replayed_file_change_approval_recovers_snapshot_changes() {
},
},
))],
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -5079,6 +5092,7 @@ async fn side_thread_snapshot_does_not_refresh_from_fork_history() {
}),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
};
@@ -5109,6 +5123,7 @@ async fn side_thread_snapshot_skips_session_header_preamble() {
}),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
};
@@ -5284,6 +5299,7 @@ async fn active_side_thread_renders_live_mcp_startup_notifications() {
)),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -8133,6 +8149,7 @@ async fn replay_thread_snapshot_replays_turn_history_in_order() {
},
],
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -8222,6 +8239,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() {
},
),
))],
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -8278,6 +8296,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() {
session: Some(initial_session),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
};
@@ -69,6 +69,7 @@ async fn switching_from_ultra_thread_restores_configured_plan_effort() {
session: Some(ultra_session),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -79,6 +80,7 @@ async fn switching_from_ultra_thread_restores_configured_plan_effort() {
session: Some(normal_session),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -2,7 +2,10 @@
use super::*;
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use pretty_assertions::assert_eq;
fn delta(thread: &str, turn: &str, item: &str) -> ServerNotification {
@@ -30,6 +33,153 @@ fn completed(thread: &str) -> ServerNotification {
})
}
#[tokio::test]
async fn refreshed_active_reasoning_accepts_later_deltas_and_complete_summary() {
for (refresh, refreshed_item_is_partial, capacity, voice_handoff) in [
(true, false, 1, false),
(true, true, 1, false),
(false, false, 8, false),
(false, false, 1, false),
(true, false, 1, true),
] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, app.config.cwd.to_path_buf());
let mut turn = test_turn("reasoning-turn", TurnStatus::InProgress, Vec::new());
let channel =
ThreadEventChannel::new_with_session(capacity, session.clone(), vec![turn.clone()]);
{
let mut store = channel.store.lock().await;
store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: turn.id.clone(),
item: ThreadItem::Reasoning {
id: "reasoning".into(),
summary: Vec::new(),
content: Vec::new(),
},
started_at_ms: 0,
}));
store.push_notification(ServerNotification::ReasoningSummaryTextDelta(
ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: turn.id.clone(),
item_id: "reasoning".into(),
delta: "**First heading**\nOriginal analysis paragraph".into(),
summary_index: 0,
},
));
assert_eq!(store.buffer.len(), capacity.min(/*other*/ 2));
if voice_handoff {
store.push_notification(ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: turn.id.clone(),
item: ThreadItem::UserMessage {
id: "voice-handoff".into(),
client_id: None,
content: vec![AppServerUserInput::Text {
text: "<realtime_delegation><input>spoken follow-up</input></realtime_delegation>".into(),
text_elements: Vec::new(),
}],
},
started_at_ms: 0,
}));
}
}
let mut snapshot = channel.store.lock().await.snapshot();
app.thread_event_channels.insert(thread_id, channel);
if refreshed_item_is_partial {
turn.items.push(ThreadItem::Reasoning {
id: "reasoning".into(),
summary: vec!["**First heading**\nOriginal analysis paragraph".into()],
content: Vec::new(),
});
}
if refresh {
app.apply_refreshed_snapshot_thread(
thread_id,
AppServerStartedThread {
session,
turns: vec![turn],
blocks_direct_input: false,
task_tools_available: false,
},
&mut snapshot,
)
.await;
assert!(snapshot.events.is_empty());
}
assert!(snapshot.active_reasoning_item.is_some());
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
app.chat_widget.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: "reasoning-turn".into(),
item_id: "reasoning".into(),
delta: "\n**Running checks**\nFinal paragraph".into(),
summary_index: 0,
}),
/*replay_kind*/ None,
);
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Final paragraph"));
let completed = ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "reasoning-turn".into(),
item: ThreadItem::Reasoning {
id: "reasoning".into(),
summary: vec![
"**First heading**\nOriginal analysis paragraph\n**Running checks**\nFinal paragraph"
.into(),
],
content: Vec::new(),
},
completed_at_ms: 0,
});
let store = &app.thread_event_channels[&thread_id].store;
store.lock().await.push_notification_ref(&completed);
assert!(
store
.lock()
.await
.snapshot()
.active_reasoning_item
.is_none()
);
app.chat_widget
.handle_server_notification(completed, /*replay_kind*/ None);
app.chat_widget.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: "reasoning-turn".into(),
item_id: "reasoning".into(),
delta: "\n**Stale heading**".into(),
summary_index: 0,
}),
/*replay_kind*/ None,
);
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Stale heading"));
let transcript = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell)
if cell
.as_any()
.is::<crate::history_cell::ReasoningSummaryCell>() =>
{
Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
}
_ => None,
})
.collect::<String>();
insta::allow_duplicates! {
insta::assert_snapshot!(transcript, @"
• Original analysis paragraph
Running checks
Final paragraph
");
}
}
}
#[test]
fn buffered_replay_keeps_unfinished_and_unrelated_deltas_in_order() {
let completion = completed("thread");
@@ -43,6 +43,7 @@ async fn subagent_mcp_startup_settles_while_cached_servers_remain_deferred() {
)),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -295,6 +295,7 @@ async fn replayed_patch_approval_pager_recovers_stored_turn_changes() {
vec![patch_item()],
)],
events: vec![ThreadBufferedEvent::Request(Box::new(request(thread_id)))],
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -66,6 +66,7 @@ fn empty_thread_snapshot(app: &App, thread_id: ThreadId) -> ThreadEventSnapshot
session: Some(test_thread_session(thread_id, app.config.cwd.to_path_buf())),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
}
}
@@ -558,6 +559,7 @@ async fn replay_reconciles_only_matching_voice_captions_one_for_one() {
session: Some(test_thread_session(source, app.config.cwd.to_path_buf())),
turns: vec![voice, typed],
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -727,6 +729,7 @@ async fn buffered_voice_items_reconcile_captions_after_thread_switch() {
session: Some(test_thread_session(source, app.config.cwd.to_path_buf())),
turns: Vec::new(),
events: events_to_replay,
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -827,6 +830,7 @@ async fn unrendered_buffered_items_do_not_consume_retained_captions() {
)],
),
],
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -1152,6 +1156,7 @@ async fn switching_threads_retains_undelivered_voice_answer_after_replay_evictio
duration_ms: None,
}],
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
@@ -0,0 +1,180 @@
//! Resume snapshots must accept an unfinished reasoning stream without an earlier item/started.
use super::*;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
#[tokio::test]
async fn resumed_reasoning_without_start_accepts_deltas_and_completion() {
for initial_replay in [false, true] {
for snapshot_item_id in [None, Some("reasoning"), Some("previous")] {
for with_delta in [false, true] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, app.config.cwd.to_path_buf());
let items = snapshot_item_id
.into_iter()
.map(|id| ThreadItem::Reasoning {
id: id.into(),
summary: vec![
if id == "previous" {
"**Earlier work**\nEarlier paragraph."
} else {
"**Inspecting**\nSnapshot paragraph."
}
.into(),
],
content: Vec::new(),
})
.collect();
let turns = vec![test_turn("turn", TurnStatus::InProgress, items)];
if initial_replay {
app.chat_widget.handle_thread_session(session);
app.chat_widget
.replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);
} else {
// Reconnect constructs a fresh store; no start notification was retained.
let store =
ThreadEventStore::new_with_session(/*capacity*/ 1, session, turns);
assert!(store.active_reasoning_item.is_none());
app.replay_thread_snapshot(
store.snapshot(),
/*resume_restored_queue*/ false,
);
}
let delta = |turn_id: &str, text: &str| {
ServerNotification::ReasoningSummaryTextDelta(
ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: turn_id.into(),
item_id: "reasoning".into(),
delta: text.into(),
summary_index: 0,
},
)
};
app.chat_widget.handle_server_notification(
delta("old-turn", "Wrong turn"),
/*replay_kind*/ None,
);
assert!(
!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Wrong turn")
);
if with_delta {
app.chat_widget.handle_server_notification(
delta("turn", "\n**Running checks**"),
/*replay_kind*/ None,
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.contains("Running checks")
);
}
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
completed_at_ms: 0,
item: ThreadItem::Reasoning {
id: "reasoning".into(),
summary: vec![
"**Inspecting**\nSnapshot paragraph.\nCompleted paragraph.".into(),
],
content: Vec::new(),
},
}),
/*replay_kind*/ None,
);
app.chat_widget.handle_server_notification(
delta("turn", "Stale update"),
/*replay_kind*/ None,
);
assert!(
!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Stale update")
);
let transcript = reasoning_transcript(&mut events);
insta::allow_duplicates! {
if snapshot_item_id == Some("previous") {
insta::assert_snapshot!(transcript, @"
• Earlier paragraph.
• Snapshot paragraph.
Completed paragraph.
");
} else {
insta::assert_snapshot!(transcript, @"
• Snapshot paragraph.
Completed paragraph.
");
}
}
}
}
}
}
#[tokio::test]
async fn resumed_trailing_reasoning_is_kept_when_no_more_reasoning_arrives() {
for next_item in [false, true] {
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, app.config.cwd.to_path_buf());
let store = ThreadEventStore::new_with_session(
/*capacity*/ 1,
session,
vec![test_turn(
"turn",
TurnStatus::InProgress,
vec![ThreadItem::Reasoning {
id: "finished".into(),
summary: vec!["**Inspecting**\nSaved paragraph.".into()],
content: Vec::new(),
}],
)],
);
app.replay_thread_snapshot(store.snapshot(), /*resume_restored_queue*/ false);
if next_item {
app.chat_widget.handle_server_notification(
ServerNotification::ItemStarted(
codex_app_server_protocol::ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
started_at_ms: 0,
item: ThreadItem::Reasoning {
id: "next".into(),
summary: Vec::new(),
content: Vec::new(),
},
},
),
/*replay_kind*/ None,
);
}
app.chat_widget.handle_server_notification(
turn_completed_notification(thread_id, "turn", TurnStatus::Completed),
/*replay_kind*/ None,
);
insta::allow_duplicates! {
insta::assert_snapshot!(reasoning_transcript(&mut events), @"
• Saved paragraph.
");
}
}
}
fn reasoning_transcript(events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String {
std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell)
if cell
.as_any()
.is::<crate::history_cell::ReasoningSummaryCell>() =>
{
Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
+1
View File
@@ -1520,6 +1520,7 @@ async fn ignore_same_thread_resume_allows_retrying_read_only_view() -> Result<()
session: Some(session),
turns: vec![test_turn("running", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
},
/*resume_restored_queue*/ false,
+64 -1
View File
@@ -14,6 +14,7 @@ pub(super) struct ThreadEventSnapshot {
pub(super) delegated_turns: Vec<String>,
pub(super) turns: Vec<Turn>,
pub(super) events: Vec<ThreadBufferedEvent>,
pub(super) active_reasoning_item: Option<codex_app_server_protocol::ItemStartedNotification>,
pub(super) input_state: Option<ThreadInputState>,
}
@@ -65,6 +66,8 @@ pub(super) struct ThreadEventStore {
pub(super) buffer: VecDeque<ThreadBufferedEvent>,
pub(super) pending_interactive_replay: PendingInteractiveReplayState,
pub(super) active_turn_id: Option<String>,
// Retain the active item even if its start falls out of the bounded replay buffer.
pub(super) active_reasoning_item: Option<codex_app_server_protocol::ItemStartedNotification>,
// Lifecycle identity must survive bounded replay-buffer eviction.
pub(super) latest_turn_id: Option<String>,
pub(super) pending_interrupt_turn_id: Option<String>,
@@ -109,6 +112,7 @@ impl ThreadEventStore {
buffer: VecDeque::new(),
pending_interactive_replay: PendingInteractiveReplayState::default(),
active_turn_id: None,
active_reasoning_item: None,
latest_turn_id: None,
pending_interrupt_turn_id: None,
input_state: None,
@@ -143,6 +147,14 @@ impl ThreadEventStore {
}
pub(super) fn set_turns(&mut self, turns: Vec<Turn>) {
if self.active_reasoning_item.as_ref().is_some_and(|started| {
turns.iter().any(|turn| {
(turn.id == started.turn_id && turn.status != TurnStatus::InProgress)
|| (turn.id != started.turn_id && turn.status == TurnStatus::InProgress)
})
}) {
self.active_reasoning_item = None;
}
self.recap_progress
.merge(recap::RecapProgress::from_turns(&turns));
self.active_turn_id = turns
@@ -185,8 +197,22 @@ impl ThreadEventStore {
match notification.as_ref() {
ServerNotification::TurnStarted(turn) => {
self.set_active_turn_id(turn.turn.id.clone());
if self
.active_reasoning_item
.as_ref()
.is_some_and(|started| started.turn_id != turn.turn.id)
{
self.active_reasoning_item = None;
}
}
ServerNotification::TurnCompleted(turn) => {
if self
.active_reasoning_item
.as_ref()
.is_some_and(|started| started.turn_id == turn.turn.id)
{
self.active_reasoning_item = None;
}
if self.active_turn_id.is_none() {
self.latest_turn_id = Some(turn.turn.id.clone());
}
@@ -210,8 +236,40 @@ impl ThreadEventStore {
}
ServerNotification::ThreadClosed(_) => {
self.active_turn_id = None;
self.active_reasoning_item = None;
self.pending_interrupt_turn_id = None;
}
ServerNotification::ItemStarted(
started @ codex_app_server_protocol::ItemStartedNotification {
item: ThreadItem::Reasoning { id, .. },
..
},
) if !self.delegated_turns.contains(&started.turn_id)
&& !self.turns.iter().any(|turn| {
turn.id == started.turn_id && turn.items.iter().any(is_voice_handoff_item)
}) =>
{
self.active_reasoning_item =
Some(codex_app_server_protocol::ItemStartedNotification {
thread_id: started.thread_id.clone(),
turn_id: started.turn_id.clone(),
item: ThreadItem::Reasoning {
id: id.clone(),
summary: Vec::new(),
content: Vec::new(),
},
started_at_ms: started.started_at_ms,
});
}
ServerNotification::ItemCompleted(completed)
if matches!(&completed.item, ThreadItem::Reasoning { .. })
&& self.active_reasoning_item.as_ref().is_some_and(|started| {
started.turn_id == completed.turn_id
&& started.item.id() == completed.item.id()
}) =>
{
self.active_reasoning_item = None;
}
_ => {}
}
@@ -313,6 +371,7 @@ impl ThreadEventStore {
})
.cloned()
.collect(),
active_reasoning_item: self.active_reasoning_item.clone(),
input_state: self.input_state.clone(),
};
if let Some(latest_turn_id) = &self.latest_turn_id {
@@ -353,7 +412,11 @@ impl ThreadEventStore {
.difference(&buffered_markers)
.cloned()
.collect::<std::collections::HashSet<_>>();
let mut typed_items = std::collections::HashSet::new();
let mut typed_items = self
.active_reasoning_item
.iter()
.map(|started| (started.turn_id.clone(), started.item.id().to_string()))
.collect::<std::collections::HashSet<_>>();
snapshot.events.retain_mut(|event| {
let ThreadBufferedEvent::Notification(notification) = event else {
return true;
+4
View File
@@ -1676,6 +1676,7 @@ impl App {
let mut store = channel.store.lock().await;
store.set_session(session.clone(), turns.clone());
store.rebase_buffer_after_session_refresh();
snapshot.active_reasoning_item = store.active_reasoning_item.clone();
}
snapshot.session = Some(session);
snapshot.turns = turns;
@@ -1766,6 +1767,7 @@ impl App {
mut snapshot: ThreadEventSnapshot,
resume_restored_queue: bool,
) {
let mut reasoning_replay = reasoning_replay::ReasoningReplay::new(&mut snapshot);
let replayed_final_items = realtime_delivery::completed_agent_items(&snapshot);
let replayed_voice_texts = realtime_delivery::replayed_voice_texts(&snapshot);
replay_filter::omit_completed_agent_deltas(&mut snapshot.events);
@@ -1832,6 +1834,7 @@ impl App {
.replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot);
}
for (event, changes) in snapshot.events.into_iter().zip(request_changes) {
reasoning_replay.before_event(&event, &mut self.chat_widget);
if suppress_replay_notices && replay_filter::event_is_notice(&event) {
continue;
}
@@ -1842,6 +1845,7 @@ impl App {
(event, _) => self.handle_thread_event_replay(event),
}
}
reasoning_replay.restore(&mut self.chat_widget);
if should_buffer_replay {
self.app_event_tx
.send(AppEvent::EndInitialHistoryReplayBuffer);
+44 -2
View File
@@ -1909,6 +1909,36 @@ fn remove_permission_config_overrides(config: &mut Option<HashMap<String, serde_
}
}
fn new_thread_reasoning_overrides(config: &Config) -> Option<HashMap<String, serde_json::Value>> {
let mut overrides = config_request_overrides_from_config(config).unwrap_or_default();
let summary = config
.model_reasoning_summary
.unwrap_or(codex_protocol::config_types::ReasoningSummary::Detailed);
overrides.insert(
"model_reasoning_summary".to_string(),
serde_json::Value::String(summary.to_string()),
);
let explicit_feature = config
.config_layer_stack
.effective_config()
.get("features")
.and_then(|features| features.get("concurrent_reasoning_summaries"))
.and_then(toml::Value::as_bool);
let features = overrides
.entry("features".to_string())
.or_insert_with(|| serde_json::json!({}));
if let Some(features) = features.as_object_mut() {
features.insert(
"concurrent_reasoning_summaries".to_string(),
serde_json::Value::Bool(
summary != codex_protocol::config_types::ReasoningSummary::None
&& explicit_feature.unwrap_or(/*default*/ false),
),
);
}
Some(overrides)
}
fn service_tier_override_from_config(config: &Config) -> Option<Option<String>> {
let local_settings = LocalSettings::from(config);
config.service_tier.clone().map(Some).or_else(|| {
@@ -2041,7 +2071,10 @@ pub(crate) fn thread_start_params_from_config(
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox,
permissions,
config: config_request_overrides_from_config(config),
config: match thread_params_mode {
ThreadParamsMode::Embedded => new_thread_reasoning_overrides(config),
ThreadParamsMode::Remote => config_request_overrides_from_config(config),
},
ephemeral: Some(config.ephemeral),
history_mode: (!config.ephemeral).then_some(ThreadHistoryMode::Paginated),
session_start_source,
@@ -2466,6 +2499,10 @@ pub(crate) fn app_server_rate_limit_snapshots(
snapshots
}
#[cfg(test)]
#[path = "app_server_session/reasoning_defaults_tests.rs"]
mod reasoning_defaults_tests;
#[cfg(test)]
#[path = "app_server_session/prompt_history_tests.rs"]
mod prompt_history_tests;
@@ -3354,7 +3391,12 @@ mod tests {
("web_search".to_string(), string("disabled")),
("bypass_hook_trust".to_string(), true.into()),
]);
assert_eq!(start.config, Some(expected_config.clone()));
let mut expected_start_config = expected_config.clone();
expected_start_config.insert(
"features".to_string(),
serde_json::json!({"concurrent_reasoning_summaries": false}),
);
assert_eq!(start.config, Some(expected_start_config));
assert_eq!(resume.config, Some(expected_config.clone()));
assert_eq!(fork.config, Some(expected_config));
}
@@ -0,0 +1,174 @@
//! Verify embedded TUI reasoning configuration and its outbound model request.
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_app_server_protocol::ServerNotification;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn embedded_reasoning_defaults_reach_responses() -> Result<()> {
for (settings, summary, stream_options) in [
("", json!("detailed"), json!(null)),
(
"[features]\nconcurrent_reasoning_summaries = false",
json!("detailed"),
json!(null),
),
(
"[features]\nconcurrent_reasoning_summaries = true",
json!("detailed"),
json!({"reasoning_summary_delivery": "sequential_cutoff"}),
),
(
"model_reasoning_summary = 'none'\n[features]\nconcurrent_reasoning_summaries = true",
json!(null),
json!(null),
),
] {
let server = responses::start_mock_server().await;
let response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("response"),
responses::ev_completed("response"),
]),
)
.await;
let home = tempfile::tempdir()?;
let base_url = server.uri();
std::fs::write(
home.path().join("config.toml"),
format!(
r#"
model = "gpt-5.2"
model_provider = "reasoning-test"
{settings}
[model_providers.reasoning-test]
name = "OpenAI"
base_url = "{base_url}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)?;
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.build()
.await?;
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
let started = app_server.start_thread(&config).await?;
let request_id = app_server.next_request_id();
let _: TurnStartResponse = app_server
.client
.request_typed(ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: started.session.thread_id.to_string(),
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await?;
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async {
while let Some(event) = app_server.next_event().await {
if let AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::TurnCompleted(completed) = *notification
{
assert_eq!(
completed.turn.status,
codex_app_server_protocol::TurnStatus::Completed
);
return;
}
}
panic!("app-server disconnected before completing the turn");
})
.await?;
let body = response.single_request().body_json();
assert_eq!(
json!({"summary": body["reasoning"]["summary"], "stream_options": body["stream_options"]}),
json!({"summary": summary, "stream_options": stream_options}),
"settings: {settings}"
);
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn new_tui_threads_request_summaries_unless_explicitly_disabled() {
let temp_dir = tempfile::tempdir().expect("tempdir");
for (config_text, expected_summary, expected_concurrent) in [
("", "detailed", false),
(
"[features]\nconcurrent_reasoning_summaries = true",
"detailed",
true,
),
("model_reasoning_summary = 'none'", "none", false),
(
"model_reasoning_summary = 'none'\n[features]\nconcurrent_reasoning_summaries = true",
"none",
false,
),
(
"model_reasoning_summary = 'concise'\n[features]\nconcurrent_reasoning_summaries = false",
"concise",
false,
),
] {
std::fs::write(temp_dir.path().join("config.toml"), config_text).expect("config");
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.build()
.await
.expect("config should build");
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let overrides = start.config.expect("thread config");
assert_eq!(
overrides.get("model_reasoning_summary"),
Some(&serde_json::json!(expected_summary)),
);
assert_eq!(
overrides["features"]["concurrent_reasoning_summaries"],
expected_concurrent,
);
}
std::fs::write(
temp_dir.path().join("config.toml"),
"model_reasoning_summary = 'detailed'",
)
.expect("config");
let config = ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.cli_overrides(vec![(
"model_reasoning_summary".to_string(),
toml::Value::String("none".to_string()),
)])
.build()
.await
.expect("override config");
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let overrides = start.config.expect("thread config");
assert_eq!(overrides["model_reasoning_summary"], "none");
assert_eq!(
overrides["features"]["concurrent_reasoning_summaries"],
false
);
}
+47 -11
View File
@@ -15,6 +15,11 @@ impl ChatWidget {
return;
}
if replay_kind != Some(ReplayKind::ResumeInitialMessages)
&& !self.recover_resumed_reasoning(&notification)
{
return;
}
let was_replaying_turn_completion = self.thread_usage.replaying_turn_completion;
self.thread_usage.replaying_turn_completion = replay_kind.is_some();
let from_replay = replay_kind.is_some();
@@ -98,7 +103,9 @@ impl ChatWidget {
if !self.is_realtime_delegated_reasoning_item(
&notification.turn_id,
&notification.item_id,
) {
) && self.status_state.reasoning_item_id.as_deref()
== Some(&notification.item_id)
{
self.on_agent_reasoning_delta(notification.delta);
}
}
@@ -108,6 +115,7 @@ impl ChatWidget {
&notification.turn_id,
&notification.item_id,
)
&& self.status_state.reasoning_item_id.as_deref() == Some(&notification.item_id)
{
self.on_agent_reasoning_delta(notification.delta);
}
@@ -116,7 +124,9 @@ impl ChatWidget {
if !self.is_realtime_delegated_reasoning_item(
&notification.turn_id,
&notification.item_id,
) {
) && self.status_state.reasoning_item_id.as_deref()
== Some(&notification.item_id)
{
self.on_reasoning_section_break();
}
}
@@ -334,6 +344,20 @@ impl ChatWidget {
| ServerNotification::ThreadProjectUpdated(_) => {}
ServerNotification::ContextCompacted(_) => {}
}
// Tool and hook activity can recreate a hidden row with its default
// heading. Restore the selected status before that row is rendered.
if self
.bottom_pane
.status_widget()
.is_some_and(|status| status.header() != self.status_state.current_status.header)
{
self.bottom_pane.update_status(
self.status_state.current_status.header.clone(),
self.status_state.current_status.details.clone(),
StatusDetailsCapitalization::Preserve,
self.status_state.current_status.details_max_lines,
);
}
self.thread_usage.replaying_turn_completion = was_replaying_turn_completion;
}
@@ -461,16 +485,22 @@ impl ChatWidget {
ThreadItem::AgentMessage { id, .. } if replay_kind.is_none() => {
self.is_realtime_delegated_agent_item(&notification.turn_id, &id);
}
ThreadItem::Reasoning { id, .. }
ThreadItem::Reasoning { id, .. } => {
if replay_kind.is_none()
&& !self.is_realtime_delegated_reasoning_turn(&notification.turn_id) =>
{
// A later voice handoff can steer this turn without making an
// already-started typed reasoning item private.
self.realtime_conversation
.agent_items
.entry((notification.turn_id.clone(), id))
.or_insert(realtime::RealtimeAgentItemOrigin::Typed);
&& !self.is_realtime_delegated_reasoning_turn(&notification.turn_id)
{
// A later voice handoff can steer this turn without making an
// already-started typed reasoning item private.
self.realtime_conversation
.agent_items
.entry((notification.turn_id.clone(), id.clone()))
.or_insert(realtime::RealtimeAgentItemOrigin::Typed);
}
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages))
&& !self.is_realtime_delegated_reasoning_item(&notification.turn_id, &id)
{
self.on_reasoning_item_started(id);
}
}
ThreadItem::ContextCompaction { id }
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) =>
@@ -552,6 +582,12 @@ impl ChatWidget {
{
self.add_async_questions(id, questions);
}
if replay_kind.is_none()
&& let ThreadItem::Reasoning { id, .. } = &notification.item
&& self.status_state.reasoning_item_id.as_ref() != Some(id)
{
return;
}
match notification.item {
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_completed(item),
item => self.handle_thread_item(
@@ -338,6 +338,12 @@ async fn delegated_reasoning_never_enters_live_history() {
}
}
assert!(!chat.is_realtime_delegated_reasoning_turn(turn_id));
let typed_reasoning = ThreadItem::Reasoning {
id: "typed-reasoning".into(),
summary: vec!["ordinary summary".into()],
content: Vec::new(),
};
start_item(&mut chat, thread_id, "typed-turn", typed_reasoning.clone());
chat.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
@@ -348,16 +354,7 @@ async fn delegated_reasoning_never_enters_live_history() {
}),
/*replay_kind*/ None,
);
complete_item(
&mut chat,
thread_id,
"typed-turn",
ThreadItem::Reasoning {
id: "typed-reasoning".into(),
summary: vec!["ordinary summary".into()],
content: Vec::new(),
},
);
complete_item(&mut chat, thread_id, "typed-turn", typed_reasoning);
let rendered = std::iter::from_fn(|| events.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(
+133 -4
View File
@@ -1,11 +1,95 @@
//! Thread replay rendering for `ChatWidget`.
//!
//! This module rehydrates turns and items into transcript state while avoiding
//! live-only side effects.
//! live-only side effects. An in-progress snapshot does not carry reasoning completion
//! state: keep its trailing reasoning provisional until live events identify the next item.
use super::*;
impl ChatWidget {
/// Restores the active typed reasoning item after a thread switch or session refresh.
/// Its completion carries the full summary if the bounded event buffer lost earlier deltas.
pub(crate) fn restore_active_reasoning_item(
&mut self,
started: codex_app_server_protocol::ItemStartedNotification,
parts: Option<(Vec<String>, Vec<String>)>,
) {
let turn_id = started.turn_id;
let id = started.item.id().to_string();
self.realtime_conversation
.agent_items
.entry((turn_id, id.clone()))
.or_insert(realtime::RealtimeAgentItemOrigin::Typed);
self.on_reasoning_item_started(id);
self.status_state.reasoning_recovered_after_refresh = true;
if let Some((summary, content)) = parts {
let reasoning_parts = summary.into_iter().chain(
self.config
.show_raw_agent_reasoning
.then_some(content)
.into_iter()
.flatten(),
);
for (index, delta) in reasoning_parts.enumerate() {
if index > 0 {
self.on_reasoning_section_break();
}
self.on_agent_reasoning_delta(delta);
}
}
}
/// The first reasoning update after resume can arrive without an item/started event.
/// Returns false for unrelated reasoning while waiting for the resumed turn's first update.
pub(super) fn recover_resumed_reasoning(&mut self, notification: &ServerNotification) -> bool {
let Some(resumed_turn_id) = self.status_state.reasoning_resume_turn_id.clone() else {
return true;
};
let (turn_id, item_id) = match notification {
ServerNotification::ReasoningSummaryTextDelta(delta) => {
(&delta.turn_id, &delta.item_id)
}
ServerNotification::ReasoningTextDelta(delta) => (&delta.turn_id, &delta.item_id),
ServerNotification::ReasoningSummaryPartAdded(part) => (&part.turn_id, &part.item_id),
ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
turn_id,
item: ThreadItem::Reasoning { id, .. },
..
},
) => (turn_id, id),
ServerNotification::ItemStarted(item)
if resumed_turn_id == item.turn_id
&& !matches!(item.item, ThreadItem::UserMessage { .. })
&& self.status_state.reasoning_item_id.as_deref() != Some(item.item.id()) =>
{
// A new item closes a trailing snapshot that was already complete at resume.
self.on_agent_reasoning_final();
return true;
}
_ => return true,
};
if resumed_turn_id != *turn_id
|| self.is_realtime_delegated_reasoning_item(turn_id, item_id)
{
return false;
}
self.restore_active_reasoning_item(
codex_app_server_protocol::ItemStartedNotification {
thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn_id: turn_id.clone(),
item: ThreadItem::Reasoning {
id: item_id.clone(),
summary: Vec::new(),
content: Vec::new(),
},
started_at_ms: 0,
},
/*parts*/ None,
);
true
}
/// Flush prior activity before live or replayed assistant text.
pub(super) fn prepare_assistant_message(&mut self) {
self.flush_unified_exec_wait_streak();
@@ -59,6 +143,13 @@ impl ChatWidget {
self.last_non_retry_error = None;
self.on_task_started();
}
let trailing_reasoning_id = (status == TurnStatus::InProgress)
.then(|| items.last())
.flatten()
.and_then(|item| match item {
ThreadItem::Reasoning { id, .. } => Some(id.clone()),
_ => None,
});
let mut replaying_delegation = false;
for item in items {
if matches!(&item, ThreadItem::UserMessage { content, .. }
@@ -74,13 +165,38 @@ impl ChatWidget {
if hidden_nested_review_turn && matches!(item, ThreadItem::UserMessage { .. }) {
continue;
}
self.replay_thread_item(item, turn_id.clone(), replay_kind);
if trailing_reasoning_id.as_deref() == Some(item.id())
&& let ThreadItem::Reasoning {
id,
summary,
content,
} = item
{
self.restore_active_reasoning_item(
codex_app_server_protocol::ItemStartedNotification {
thread_id: self.thread_id.map(|id| id.to_string()).unwrap_or_default(),
turn_id: turn_id.clone(),
item: ThreadItem::Reasoning {
id,
summary: Vec::new(),
content: Vec::new(),
},
started_at_ms: 0,
},
Some((summary, content)),
);
} else {
self.replay_thread_item(item, turn_id.clone(), replay_kind);
}
}
let status = if hidden_nested_review_turn {
TurnStatus::Completed
} else {
status
};
if status == TurnStatus::InProgress {
self.status_state.reasoning_resume_turn_id = Some(turn_id.clone());
}
// A resolved historical precaution must not clear the restored draft or input queue.
if Some(&turn_id) != latest_turn_id.as_ref()
&& error.as_ref().is_some_and(|error| {
@@ -201,9 +317,22 @@ impl ChatWidget {
}
ThreadItem::Plan { text, .. } => self.on_plan_item_completed(text),
ThreadItem::Reasoning {
summary, content, ..
id,
summary,
content,
} => {
if from_replay {
let recover_completion = self.status_state.reasoning_recovered_after_refresh
&& (!summary.is_empty()
|| (self.config.show_raw_agent_reasoning && !content.is_empty()));
if from_replay || recover_completion {
if from_replay {
self.on_reasoning_item_started(id);
} else {
// A refreshed snapshot can omit the active item and its earlier deltas.
// Reconcile with the complete item before committing the transcript.
self.reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
}
let reasoning_parts = summary.into_iter().chain(
self.config
.show_raw_agent_reasoning
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/reasoning_status_tests.rs
expression: terminal.backend()
---
"• Preparing evidence report (42s • esc …"
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/reasoning_status_tests.rs
expression: terminal.backend()
---
"• Preparing evidence report (42s • esc to interrupt) · 1 background terminal ru…"
@@ -1,9 +1,10 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
assertion_line: 4833
expression: normalized_backend_snapshot(terminal.backend())
---
" "
"• Checking files (0s • esc to interrupt) · 1 background terminal running · /ps …"
"• Checking files and preparing a response (0s • esc to interrupt) · 1 backgroun…"
" "
" "
"› Ask Codex to do anything "
@@ -110,6 +110,12 @@ impl PendingGuardianReviewStatus {
#[derive(Debug)]
pub(super) struct StatusState {
/// Only the active reasoning item may update the streamed heading.
pub(super) reasoning_item_id: Option<String>,
/// An in-progress snapshot can omit the start of its next live reasoning update.
pub(super) reasoning_resume_turn_id: Option<String>,
/// A restored stream may have missed earlier deltas; use its complete item at finalization.
pub(super) reasoning_recovered_after_refresh: bool,
pub(super) compaction: Option<super::compaction::ActiveCompaction>,
pub(super) current_status: StatusIndicatorState,
pub(super) pending_guardian_review_status: PendingGuardianReviewStatus,
@@ -122,6 +128,9 @@ pub(super) struct StatusState {
impl Default for StatusState {
fn default() -> Self {
Self {
reasoning_item_id: None,
reasoning_resume_turn_id: None,
reasoning_recovered_after_refresh: false,
compaction: None,
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
+63 -13
View File
@@ -5,15 +5,56 @@
use super::*;
impl ChatWidget {
pub(super) fn restore_reasoning_status_header(&mut self) {
if self.reasoning_header.is_none() {
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
fn latest_summary_line(text: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with("<!--") {
return None;
}
let line = line.trim_start_matches('#').trim();
let line = if let Some(stripped) = line.strip_prefix("**") {
let (bold, trailing) = stripped.split_once("**")?;
format!("{bold}{trailing}")
} else {
line.to_string()
};
(!line.is_empty()).then_some(line)
})
}
impl ChatWidget {
pub(super) fn on_reasoning_item_started(&mut self, id: String) {
if self.status_state.reasoning_resume_turn_id.take().is_some()
&& self.status_state.reasoning_item_id.as_ref() != Some(&id)
{
self.on_agent_reasoning_final();
}
if self.status_state.reasoning_item_id.as_ref() == Some(&id) {
return;
}
self.status_state.reasoning_item_id = Some(id);
self.status_state.reasoning_recovered_after_refresh = false;
self.reasoning_buffer.clear();
self.reasoning_summary_parts.clear();
self.restore_reasoning_status_header();
}
pub(super) fn restore_reasoning_status_header(&mut self) {
if self.safety_buffering_is_waiting()
|| self.unified_exec_wait_streak.is_some()
|| self.status_state.compaction.is_some()
|| !self.status_state.pending_guardian_review_status.is_empty()
{
return;
}
self.reasoning_header =
latest_summary_line(&self.reasoning_buffer).or(self.reasoning_header.take());
if let Some(header) = self.reasoning_header.clone() {
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(header);
} else if self.bottom_pane.is_task_running() {
} else if self.bottom_pane.is_task_running()
|| self.status_state.current_status.is_guardian_review()
{
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working;
self.set_status_header(String::from("Working"));
}
@@ -230,9 +271,7 @@ impl ChatWidget {
}
pub(super) fn on_agent_reasoning_delta(&mut self, delta: String) {
// For reasoning deltas, do not stream to history. Accumulate the
// current reasoning block and extract the first bold element
// (between **/**) as the chunk header. Show this header as status.
// Accumulate the current reasoning block for history and activity text.
self.reasoning_buffer.push_str(&delta);
if self.safety_buffering_is_waiting() {
@@ -244,11 +283,14 @@ impl ChatWidget {
return;
}
if self.reasoning_header.is_none() {
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
if !self.status_state.pending_guardian_review_status.is_empty() {
return;
}
self.reasoning_header =
latest_summary_line(&self.reasoning_buffer).or(self.reasoning_header.take());
let Some(header) = self.reasoning_header.as_deref() else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
// No usable summary has arrived yet.
return;
};
@@ -279,13 +321,22 @@ impl ChatWidget {
self.reasoning_summary_parts
.push(std::mem::take(&mut self.reasoning_buffer));
}
self.reasoning_header = self
.reasoning_summary_parts
.iter()
.rev()
.find_map(|part| latest_summary_line(part))
.or(self.reasoning_header.take());
if !self.reasoning_summary_parts.is_empty() {
let reasoning_parts = std::mem::take(&mut self.reasoning_summary_parts);
let cell = history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
self.add_boxed_history(cell);
}
self.reasoning_buffer.clear();
self.reasoning_header = None;
// Keep the last useful summary through tools and later empty items.
self.status_state.reasoning_item_id = None;
self.status_state.reasoning_resume_turn_id = None;
self.status_state.reasoning_recovered_after_refresh = false;
self.reasoning_summary_parts.clear();
self.request_redraw();
}
@@ -296,7 +347,6 @@ impl ChatWidget {
self.reasoning_summary_parts
.push(std::mem::take(&mut self.reasoning_buffer));
}
self.reasoning_header = None;
}
pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
+2
View File
@@ -267,6 +267,8 @@ mod plugin_catalog;
mod popups_and_settings;
#[path = "tests/rate_limit_recovery_tests.rs"]
mod rate_limit_recovery_tests;
#[path = "tests/reasoning_status_tests.rs"]
mod reasoning_status_tests;
#[path = "tests/replay_render_tests.rs"]
mod replay_render_tests;
mod review_mode;
@@ -532,7 +532,30 @@ pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into
);
}
pub(super) fn handle_agent_reasoning_started(chat: &mut ChatWidget, id: impl Into<String>) {
chat.handle_server_notification(
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
started_at_ms: 0,
item: AppServerThreadItem::Reasoning {
id: id.into(),
summary: Vec::new(),
content: Vec::new(),
},
}),
/*replay_kind*/ None,
);
}
pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl Into<String>) {
if chat.status_state.reasoning_item_id.is_none() {
handle_agent_reasoning_started(chat, "reasoning-1");
}
chat.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id(chat),
@@ -550,6 +573,9 @@ pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl In
}
pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) {
if chat.status_state.reasoning_item_id.is_none() {
handle_agent_reasoning_started(chat, "reasoning-1");
}
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
@@ -1386,6 +1386,8 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() {
);
let _ = drain_insert_history(&mut rx);
handle_agent_reasoning_started(&mut chat, "reasoning-1");
chat.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: "thread-1".to_string(),
@@ -1443,6 +1445,8 @@ async fn live_reasoning_summary_drops_empty_parts_without_losing_content() {
);
let _ = drain_insert_history(&mut rx);
handle_agent_reasoning_started(&mut chat, "reasoning-1");
for (summary_index, delta) in [
(0, "**Plan**\n\ndone"),
(1, "**Checking tests**\n\n<!-- -->"),
@@ -0,0 +1,317 @@
//! Reasoning activity retains the latest usable line through empty items and status-row replacement.
use super::*;
use pretty_assertions::assert_eq;
fn delta(chat: &mut ChatWidget, id: &str, text: &str) {
chat.handle_server_notification(
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: id.to_string(),
delta: text.to_string(),
summary_index: 0,
}),
/*replay_kind*/ None,
);
}
fn complete(chat: &mut ChatWidget, id: &str) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: AppServerThreadItem::Reasoning {
id: id.to_string(),
summary: Vec::new(),
content: Vec::new(),
},
}),
/*replay_kind*/ None,
);
}
#[tokio::test]
async fn reasoning_status_accepts_bold_text_with_a_plain_suffix() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_agent_reasoning_started(&mut chat, "reasoning");
delta(&mut chat, "reasoning", "**Checking tests");
let before_close = chat
.bottom_pane
.status_widget()
.unwrap()
.header()
.to_string();
delta(&mut chat, "reasoning", "**: running suite");
let after_close = chat
.bottom_pane
.status_widget()
.unwrap()
.header()
.to_string();
insta::assert_debug_snapshot!(vec![before_close, after_close], @r###"
[
"Working",
"Checking tests: running suite",
]
"###);
}
#[tokio::test]
async fn reasoning_status_tracks_items_and_restores_after_tool_activity() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
let mut headers = Vec::new();
let capture = |chat: &ChatWidget, headers: &mut Vec<String>| {
headers.push(
chat.bottom_pane
.status_widget()
.expect("running status")
.header()
.to_string(),
);
};
handle_agent_reasoning_started(&mut chat, "first");
delta(&mut chat, "first", "**Researching backend");
capture(&chat, &mut headers);
delta(&mut chat, "first", " freshness**");
capture(&chat, &mut headers);
complete(&mut chat, "first");
delta(&mut chat, "first", "**Late old header**");
capture(&chat, &mut headers);
// A tool recreates the row after streamed commentary has hidden it.
chat.bottom_pane.hide_status_indicator();
begin_unified_exec_startup(&mut chat, "tool-1", "process-1", "sleep 2");
capture(&chat, &mut headers);
handle_agent_reasoning_started(&mut chat, "second");
capture(&chat, &mut headers);
chat.handle_server_notification(
ServerNotification::ReasoningSummaryPartAdded(
codex_app_server_protocol::ReasoningSummaryPartAddedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "first".to_string(),
summary_index: 1,
},
),
/*replay_kind*/ None,
);
complete(&mut chat, "first");
delta(&mut chat, "first", "**Stale heading**");
delta(&mut chat, "second", "No summary heading is available.");
capture(&chat, &mut headers);
delta(&mut chat, "second", "\n**Preparing evidence report**");
capture(&chat, &mut headers);
insta::assert_debug_snapshot!(headers);
let mut timer = crate::status_indicator_widget::StatusTimer::default();
timer.pause_at(std::time::Instant::now());
timer.reset(std::time::Duration::from_secs(/*secs*/ 42));
for width in [80, 40] {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, /*height*/ 1))
.expect("terminal");
terminal
.draw(|frame| {
chat.bottom_pane
.status_widget()
.expect("running status")
.with_timer(&timer)
.render(frame.area(), frame.buffer_mut());
})
.expect("render reasoning status");
assert_chatwidget_snapshot!(
format!("reasoning_activity_row_{width}"),
terminal.backend()
);
}
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
delta(&mut chat, "second", "**After interruption**");
assert_eq!(chat.status_state.reasoning_item_id, None);
assert_eq!(chat.reasoning_header, None);
assert!(!chat.bottom_pane.status_indicator_visible());
chat.on_task_started();
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Working"
);
}
#[tokio::test]
async fn voice_handoff_preserves_typed_reasoning_and_ignores_private_items() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_agent_reasoning_started(&mut chat, "typed");
delta(&mut chat, "typed", "**Checking repository**");
chat.remember_realtime_delegated_reasoning_turn("turn-1");
handle_agent_reasoning_started(&mut chat, "private");
delta(&mut chat, "private", "**Private voice reasoning**");
complete(&mut chat, "private");
assert_eq!(
chat.status_state.reasoning_item_id.as_deref(),
Some("typed")
);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Checking repository"
);
delta(&mut chat, "typed", "\n**Verifying changes**");
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Verifying changes"
);
complete(&mut chat, "typed");
assert_eq!(chat.status_state.reasoning_item_id, None);
}
#[tokio::test]
async fn reasoning_status_preserves_an_explicit_wait_and_restores_the_heading() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.status_state
.pending_guardian_review_status
.start_or_update("review-1".to_string(), "Checking a command".to_string());
chat.set_status_header("Reviewing approval request".to_string());
handle_agent_reasoning_started(&mut chat, "first");
delta(&mut chat, "first", "**Choosing the missing-data fix**");
complete(&mut chat, "first");
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Reviewing approval request"
);
chat.status_state
.pending_guardian_review_status
.finish("review-1");
chat.restore_reasoning_status_header();
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Choosing the missing-data fix"
);
}
#[tokio::test]
async fn reasoning_status_replay_retains_last_usable_heading() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
for (id, summary, expected) in [
(
"first",
vec!["**First heading**".to_string()],
"First heading",
),
(
"second",
vec!["**Second heading**".to_string()],
"Second heading",
),
("third", Vec::new(), "Second heading"),
] {
chat.handle_thread_item(
AppServerThreadItem::Reasoning {
id: id.to_string(),
summary,
content: Vec::new(),
},
"turn-1".to_string(),
ThreadItemRenderSource::Replay(ReplayKind::ThreadSnapshot),
);
assert_eq!(chat.bottom_pane.status_widget().unwrap().header(), expected);
}
}
#[tokio::test]
async fn reasoning_status_accepts_plain_lines_and_ignores_empty_sections() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
handle_agent_reasoning_started(&mut chat, "first");
delta(
&mut chat,
"first",
"# Checking files\n<!-- progress -->\nPreparing response",
);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Preparing response"
);
complete(&mut chat, "first");
handle_agent_reasoning_started(&mut chat, "second");
delta(&mut chat, "second", "<!-- no public update -->");
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Preparing response"
);
delta(&mut chat, "second", "\n**Verifying results**");
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Verifying results"
);
}
#[tokio::test]
async fn completed_reasoning_stays_in_expanded_transcript_for_live_and_replay() {
let mut renders = Vec::new();
for replay in [false, true] {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
for (id, summary) in [
("first", "**Inspecting repository structure**"),
("second", "Mapping the app structure"),
(
"third",
"**Tracing terminal components**\n\nThe playback clock preserves elapsed time.",
),
] {
if replay {
chat.handle_thread_item(
AppServerThreadItem::Reasoning {
id: id.to_string(),
summary: vec![summary.to_string()],
content: Vec::new(),
},
"turn-1".to_string(),
ThreadItemRenderSource::Replay(ReplayKind::ThreadSnapshot),
);
} else {
handle_agent_reasoning_started(&mut chat, id);
delta(&mut chat, id, summary);
complete(&mut chat, id);
}
}
let mut compact = Vec::new();
let mut expanded = Vec::new();
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
compact.extend(cell.display_lines(/*width*/ 80));
expanded.extend(cell.transcript_lines(/*width*/ 80));
assert!(cell.raw_lines().is_empty());
}
}
let rendered = format!(
"Compact history:\n{}\nLive status:\n{}\nExpanded transcript:\n{}",
ratatui::text::Text::from(compact),
chat.bottom_pane.status_widget().unwrap().header(),
ratatui::text::Text::from(expanded),
);
renders.push(rendered);
}
assert_eq!(renders[0], renders[1]);
insta::assert_snapshot!(renders[0], @r"
Compact history:
Live status:
The playback clock preserves elapsed time.
Expanded transcript:
• Inspecting repository structure
• Mapping the app structure
• The playback clock preserves elapsed time.
");
}
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/reasoning_status_tests.rs
assertion_line: 84
expression: headers
---
[
"Working",
"Researching backend freshness",
"Researching backend freshness",
"Researching backend freshness",
"Researching backend freshness",
"No summary heading is available.",
"Preparing evidence report",
]
@@ -4744,29 +4744,29 @@ async fn regular_commit_tick_clears_orphaned_plan_stream_tail() {
}
#[tokio::test]
async fn reasoning_delta_redraws_only_when_header_becomes_visible() {
async fn reasoning_delta_redraws_when_latest_usable_line_changes() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
chat.frame_requester = frame_requester;
chat.on_agent_reasoning_delta("still looking".to_string());
chat.on_agent_reasoning_delta("**Checking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
assert_eq!(chat.reasoning_header, None);
chat.on_agent_reasoning_delta(" **Checking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
chat.on_agent_reasoning_delta(" files**".to_string());
assert!(draw_rx.try_recv().is_ok());
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
chat.on_agent_reasoning_delta("\nPreparing a response".to_string());
assert!(draw_rx.try_recv().is_ok());
assert_eq!(
chat.reasoning_header.as_deref(),
Some("Preparing a response")
);
chat.on_agent_reasoning_delta("".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
@@ -4813,7 +4813,7 @@ async fn reasoning_delta_restores_recreated_status_indicator_header() {
.bottom_pane
.status_widget()
.expect("status indicator should be recreated");
assert_eq!(status.header(), "Working");
assert_eq!(status.header(), "Checking files");
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
@@ -4821,7 +4821,7 @@ async fn reasoning_delta_restores_recreated_status_indicator_header() {
.bottom_pane
.status_widget()
.expect("status indicator should remain visible");
assert_eq!(status.header(), "Checking files");
assert_eq!(status.header(), "Checking files and preparing a response");
let width: u16 = 80;
let height = chat.desired_height(width);
+2 -2
View File
@@ -142,12 +142,12 @@ impl ChatWidget {
status.details_max_lines,
);
} else if self.status_state.current_status.is_guardian_review() {
self.set_status_header(String::from("Working"));
self.restore_reasoning_status_header();
}
} else if self.status_state.pending_guardian_review_status.is_empty()
&& self.status_state.current_status.is_guardian_review()
{
self.set_status_header(String::from("Working"));
self.restore_reasoning_status_header();
}
if ev.status == GuardianAssessmentStatus::Approved {
@@ -103,6 +103,8 @@ impl ChatWidget {
self.reasoning_summary_parts.clear();
self.reasoning_buffer.clear();
self.reasoning_header = None;
self.status_state.reasoning_item_id = None;
self.status_state.reasoning_resume_turn_id = None;
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Running,
/*body*/ None,
@@ -116,6 +118,9 @@ impl ChatWidget {
completion: Option<history_cell::FinalMessageSeparator>,
from_replay: bool,
) {
if self.status_state.reasoning_resume_turn_id.is_some() {
self.on_agent_reasoning_final();
}
self.input_queue.submit_pending_steers_after_interrupt = false;
let sanitized_last_agent_message = last_agent_message.as_deref().map(|message| {
parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown
@@ -174,6 +179,9 @@ impl ChatWidget {
// Mark task stopped and request redraw now that all content is in history.
self.clear_context_compaction();
self.status_state.pending_status_indicator_restore = false;
self.status_state.reasoning_item_id = None;
self.status_state.reasoning_resume_turn_id = None;
self.reasoning_header = None;
self.input_queue.user_turn_pending_start = false;
self.clear_active_hook_cell();
self.clear_guardian_review_status();
@@ -314,6 +322,12 @@ impl ChatWidget {
/// This does not clear MCP startup tracking, because MCP startup can overlap with turn cleanup
/// and should continue to drive the bottom-pane running indicator while it is in progress.
pub(super) fn finalize_turn(&mut self) {
if self.status_state.reasoning_resume_turn_id.is_some() {
self.on_agent_reasoning_final();
}
self.status_state.reasoning_item_id = None;
self.status_state.reasoning_resume_turn_id = None;
self.reasoning_header = None;
self.clear_context_compaction();
self.clear_safety_buffering();
// Drop preview-only stream tail content on any termination path before
+4 -10
View File
@@ -1,4 +1,5 @@
//! User, assistant, reasoning, and streaming message history cells.
//! Completed reasoning is retained in the expanded transcript, not compact scrollback.
use super::markdown_render_cache::MarkdownRenderCache;
use super::*;
@@ -658,7 +659,8 @@ pub(crate) fn new_spoken_user_prompt(message: String) -> UserHistoryCell {
cell
}
/// Create the reasoning history cell emitted at the end of a reasoning block.
/// Retain a completed reasoning block in the expanded transcript only.
/// Activity headings belong to the live status row and must not accumulate in scrollback.
///
/// The helper snapshots `cwd` into the returned cell so local file links render the same way they
/// did while the turn was live, even if rendering happens after other app state has advanced. Part
@@ -669,16 +671,8 @@ pub(crate) fn new_reasoning_summary_block(
cwd: &Path,
) -> Box<dyn HistoryCell> {
let (header, content) = split_reasoning_summary_parts(&reasoning_parts);
let title_only = content
.strip_prefix("**")
.and_then(|content| content.strip_suffix("**"))
.is_some_and(|content| !content.is_empty() && !content.contains("**"));
let transcript_only = header.is_empty() && !title_only;
Box::new(ReasoningSummaryCell::new(
header,
content,
cwd,
transcript_only,
header, content, cwd, /*transcript_only*/ true,
))
}
+10 -13
View File
@@ -2670,7 +2670,7 @@ fn reasoning_summary_block() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
@@ -2742,7 +2742,7 @@ async fn reasoning_summary_block_respects_config_overrides() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
assert_eq!(rendered_display, Vec::<String>::new());
}
#[test]
@@ -2776,17 +2776,14 @@ fn reasoning_summary_block_falls_back_when_summary_is_missing() {
}
#[test]
fn reasoning_summary_block_displays_title_only_summary() {
fn reasoning_summary_block_keeps_title_only_summary_in_expanded_transcript() {
let cell = new_reasoning_summary_block(
vec!["**Confirming backend JSONL source**".to_string()],
&test_cwd(),
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(
rendered_display.join("\n"),
@"• Confirming backend JSONL source"
);
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(
@@ -2803,7 +2800,7 @@ fn reasoning_summary_block_splits_header_and_summary_when_present() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
@@ -2820,7 +2817,7 @@ fn reasoning_summary_block_hides_empty_html_comment_parts() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered_display.join("\n"), @"");
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, Vec::<String>::new());
@@ -2838,7 +2835,7 @@ fn reasoning_summary_block_preserves_bold_content_after_empty_html_comment_part(
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered_display.join("\n"), @"• Important conclusion");
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• Important conclusion"]);
@@ -2866,7 +2863,7 @@ fn reasoning_summary_block_strips_header_after_leading_empty_part() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered_display.join("\n"), @"• Tests passed");
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• Tests passed"]);
@@ -2883,7 +2880,7 @@ fn reasoning_summary_block_drops_empty_part_after_real_content() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered_display.join("\n"), @"• done");
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• done"]);
@@ -2897,7 +2894,7 @@ fn reasoning_summary_block_preserves_literal_html_comment() {
);
let rendered_display = render_lines(&cell.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered_display.join("\n"), @"• Use <!-- --> in JSX.");
assert_eq!(rendered_display, Vec::<String>::new());
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• Use <!-- --> in JSX."]);
@@ -0,0 +1,35 @@
---
source: tui/src/summary_shimmer_tests.rs
assertion_line: 27
expression: "frames.join(\"\\n\")"
---
dark Working 0ms: [128, 128, 128, 128, 128, 128, 128]
dark Working 500ms: [238, 198, 144, 128, 128, 128, 128]
dark Working 1000ms: [128, 156, 212, 240, 212, 156, 128]
dark Working 1500ms: [128, 128, 128, 128, 144, 198, 238]
dark Working 2000ms: [128, 128, 128, 128, 128, 128, 128]
dark Preparing bootstrap diagnostics 0ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
dark Preparing bootstrap diagnostics 500ms: [128, 128, 128, 132, 175, 226, 237, 198, 145, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
dark Preparing bootstrap diagnostics 1000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 159, 213, 240, 213, 159, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
dark Preparing bootstrap diagnostics 1500ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 145, 198, 237, 226, 175, 132, 128, 128, 128]
dark Preparing bootstrap diagnostics 2000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
dark 界é界 0ms: [128, 128, 128]
dark 界é界 500ms: [198, 129, 128]
dark 界é界 1000ms: [184, 240, 184]
dark 界é界 1500ms: [128, 129, 198]
dark 界é界 2000ms: [128, 128, 128]
light Working 0ms: [128, 128, 128, 128, 128, 128, 128]
light Working 500ms: [17, 57, 111, 128, 128, 128, 128]
light Working 1000ms: [128, 100, 44, 16, 44, 100, 128]
light Working 1500ms: [128, 128, 128, 128, 111, 57, 17]
light Working 2000ms: [128, 128, 128, 128, 128, 128, 128]
light Preparing bootstrap diagnostics 0ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
light Preparing bootstrap diagnostics 500ms: [128, 128, 128, 123, 80, 29, 18, 57, 110, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
light Preparing bootstrap diagnostics 1000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 127, 96, 42, 16, 42, 96, 127, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
light Preparing bootstrap diagnostics 1500ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 110, 57, 18, 29, 80, 123, 128, 128, 128]
light Preparing bootstrap diagnostics 2000ms: [128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128]
light 界é界 0ms: [128, 128, 128]
light 界é界 500ms: [57, 126, 128]
light 界é界 1000ms: [72, 16, 72]
light 界é界 1500ms: [128, 126, 57]
light 界é界 2000ms: [128, 128, 128]
+31 -3
View File
@@ -28,7 +28,6 @@ use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::motion::shimmer_text;
use crate::render::renderable::Renderable;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester;
@@ -39,6 +38,10 @@ use crate::wrapping::word_wrap_lines;
mod timer;
pub(crate) use timer::StatusTimer;
#[path = "summary_shimmer.rs"]
mod summary_shimmer;
use summary_shimmer::summary_shimmer;
pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3;
const DETAILS_PREFIX: &str = " └ ";
@@ -52,6 +55,7 @@ pub(crate) enum StatusDetailsCapitalization {
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
header: String,
header_started_at: Instant,
details: Option<String>,
details_max_lines: usize,
/// Optional suffix rendered after the elapsed/interrupt segment.
@@ -91,6 +95,7 @@ impl StatusIndicatorWidget {
) -> Self {
Self {
header: String::from("Working"),
header_started_at: Instant::now(),
details: None,
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
inline_message: None,
@@ -109,7 +114,10 @@ impl StatusIndicatorWidget {
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
if self.header != header {
self.header = header;
self.header_started_at = Instant::now();
}
}
/// Update the details text shown below the header.
@@ -227,7 +235,11 @@ impl StatusIndicator<'_> {
spans.push(indicator);
spans.push(" ".into());
}
spans.extend(shimmer_text(&row.header, motion_mode));
spans.extend(summary_shimmer(
&row.header,
now.saturating_duration_since(row.header_started_at),
motion_mode,
));
if !spans.is_empty() {
spans.push(" ".into());
}
@@ -308,6 +320,22 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn changed_summary_restarts_shimmer_but_repeated_summary_keeps_phase() {
let (tx, _rx) = unbounded_channel();
let mut row = StatusIndicatorWidget::new(
AppEventSender::new(tx),
FrameRequester::test_dummy(),
/*animations_enabled*/ true,
);
let previous = Instant::now() - Duration::from_secs(/*secs*/ 1);
row.header_started_at = previous;
row.update_header("Working".to_owned());
assert_eq!(row.header_started_at, previous);
row.update_header("Mapping the app structure".to_owned());
assert!(row.header_started_at > previous);
}
#[test]
fn fmt_elapsed_compact_formats_seconds_minutes_hours() {
assert_eq!(fmt_elapsed_compact(/*elapsed_secs*/ 0), "0s");
+56
View File
@@ -0,0 +1,56 @@
//! Smooth, whole-grapheme status shimmer with a two-second sweep.
//!
//! Only brightness changes: the moving band uses the terminal foreground, while
//! the remaining text blends halfway into the background. The wave spans at
//! least six terminal columns so short labels do not flash one letter at a time.
//! Unknown palettes use static dim text instead of a stepped animation.
use std::time::Duration;
use ratatui::style::Style;
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::color::blend;
use crate::motion::MotionMode;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::default_fg;
use crate::terminal_palette::effective_stdout_color_level;
use crate::terminal_palette::rgb_color;
pub(crate) fn summary_shimmer(
text: &str,
elapsed: Duration,
motion: MotionMode,
) -> Vec<Span<'static>> {
if motion == MotionMode::Reduced {
return vec![text.to_owned().into()];
}
let (StdoutColorLevel::TrueColor, Some(fg), Some(bg)) =
(effective_stdout_color_level(), default_fg(), default_bg())
else {
return vec![Span::styled(text.to_owned(), Style::default().dim())];
};
let width = text.width() as f64;
let half_width = (width * 0.1).max(/*other*/ 3.0);
let position = (elapsed.as_secs_f64() % 2.0) / 2.0 * (width + 2.0 * half_width) - half_width;
let mut column = 0.0;
text.graphemes(/*is_extended*/ true)
.map(|grapheme| {
let glyph_width = grapheme.width() as f64;
let center = column + glyph_width / 2.0;
column += glyph_width;
let distance = ((center - position).abs() / half_width).min(/*other*/ 1.0);
let intensity = 0.5 * (1.0 + (std::f64::consts::PI * distance).cos());
let alpha = (0.5 + 0.5 * intensity) as f32;
let style = Style::default().fg(rgb_color(blend(fg, bg, alpha)));
Span::styled(grapheme.to_owned(), style)
})
.collect()
}
#[cfg(test)]
#[path = "summary_shimmer_tests.rs"]
mod tests;
+114
View File
@@ -0,0 +1,114 @@
use super::*;
use pretty_assertions::assert_eq;
use crate::terminal_palette::with_test_default_colors;
use crate::terminal_probe::DefaultColors;
#[test]
fn short_and_long_labels_sweep_smoothly_in_both_themes() {
let mut frames = Vec::new();
for (theme, colors) in [
(
"dark",
DefaultColors {
fg: (240, 240, 240),
bg: (16, 16, 16),
},
),
(
"light",
DefaultColors {
fg: (16, 16, 16),
bg: (240, 240, 240),
},
),
] {
with_test_default_colors(colors, || {
for text in ["Working", "Preparing bootstrap diagnostics", "界e\u{301}界"] {
for ms in [0, 500, 1000, 1500, 2000] {
let spans =
summary_shimmer(text, Duration::from_millis(ms), MotionMode::Animated);
let levels = spans
.iter()
.map(|span| match span.style.fg {
Some(ratatui::style::Color::Rgb(level, _, _)) => level,
color => panic!("expected interpolated RGB, got {color:?}"),
})
.collect::<Vec<_>>();
frames.push(format!("{theme} {text} {ms}ms: {levels:?}"));
}
}
});
}
insta::assert_snapshot!(frames.join("\n"));
}
#[test]
fn working_has_overlapping_highlights_without_frame_to_frame_flashes() {
with_test_default_colors(
DefaultColors {
fg: (240, 240, 240),
bg: (16, 16, 16),
},
|| {
let mut previous = Vec::new();
for ms in (0..=2000).step_by(/*step*/ 16) {
let spans =
summary_shimmer("Working", Duration::from_millis(ms), MotionMode::Animated);
let brightness = spans
.iter()
.map(|span| match span.style.fg {
Some(ratatui::style::Color::Rgb(r, _, _)) => r,
color => panic!("expected interpolated RGB, got {color:?}"),
})
.collect::<Vec<_>>();
if brightness.iter().any(|value| *value > 220) {
assert!(brightness.iter().filter(|value| **value > 160).count() >= 2);
}
for (current, previous) in brightness.iter().zip(&previous) {
assert!(u8::abs_diff(*current, *previous) <= 7);
}
previous = brightness;
}
let midpoint = summary_shimmer(
"Working",
Duration::from_secs(/*secs*/ 1),
MotionMode::Animated,
);
let expected = "Working"
.chars()
.zip([128, 156, 212, 240, 212, 156, 128])
.map(|(ch, level)| {
Span::styled(
ch.to_string(),
Style::default().fg(rgb_color((level, level, level))),
)
})
.collect::<Vec<_>>();
assert_eq!(midpoint, expected);
},
);
}
#[test]
fn sweep_preserves_combining_characters_and_emoji_clusters() {
let text = "e\u{301}👨‍👩‍👧‍👦界";
let spans = with_test_default_colors(
DefaultColors {
fg: (240, 240, 240),
bg: (16, 16, 16),
},
|| summary_shimmer(text, Duration::ZERO, MotionMode::Animated),
);
assert_eq!(
spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec!["e\u{301}", "👨‍👩‍👧‍👦", "界"]
);
assert_eq!(
summary_shimmer(text, Duration::from_secs(/*secs*/ 1), MotionMode::Reduced),
vec![Span::from(text.to_owned())]
);
}