fix(tui): conditionally restore status indicator using message phase (#10947)

TLDR: use new message phase field emitted by preamble-supported models
to determine whether an AgentMessage is mid-turn commentary. if so,
restore the status indicator afterwards to indicate the turn has not
completed.

### Problem
`commit_tick` hides the status indicator while streaming assistant text.
For preamble-capable models, that text can be commentary mid-turn, so
hiding was correct during streaming but restore timing mattered:
- restoring too aggressively caused jitter/flashing
- not restoring caused indicator to stay hidden before subsequent work
(tool calls, web search, etc.)

### Fix
- Add optional `phase` to `AgentMessageItem` and propagate it from
`ResponseItem::Message`
- Keep indicator hidden during streamed commit ticks, restore only when:
  - assistant item completes as `phase=commentary`, and
  - stream queues are idle + task is still running.
- Treat `phase=None` as final-answer behavior (no restore) to keep
existing behavior for non-preamble models

### Tests
Add/update tests for:
- no idle-tick restore without commentary completion
- commentary completion restoring status before tool begin
- snapshot coverage for preamble/status behavior

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
sayan-oai
2026-02-06 18:39:52 -08:00
committed by GitHub
parent 1446bd2b23
commit 5d2702f6b8
19 changed files with 527 additions and 80 deletions

View File

@@ -20,6 +20,11 @@
//! is in progress and while MCP server startup is in progress. Those lifecycles are tracked
//! independently (`agent_turn_running` and `mcp_startup_status`) and synchronized via
//! `update_task_running_state`.
//!
//! For preamble-capable models, assistant output may include commentary before
//! the final answer. During streaming we hide the status row to avoid duplicate
//! progress indicators; once commentary completes and stream queues drain, we
//! re-show it so users still see turn-in-progress state between output bursts.
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -116,6 +121,8 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::Settings;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::request_user_input::RequestUserInputEvent;
@@ -542,6 +549,8 @@ pub(crate) struct ChatWidget {
current_status_header: String,
// Previous status header to restore after a transient stream retry.
retry_status_header: Option<String>,
// Set when commentary output completes; once stream queues go idle we restore the status row.
pending_status_indicator_restore: bool,
thread_id: Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
@@ -808,6 +817,37 @@ impl ChatWidget {
self.adaptive_chunking.reset();
}
fn stream_controllers_idle(&self) -> bool {
self.stream_controller
.as_ref()
.map(|controller| controller.queued_lines() == 0)
.unwrap_or(true)
&& self
.plan_stream_controller
.as_ref()
.map(|controller| controller.queued_lines() == 0)
.unwrap_or(true)
}
/// Restore the status indicator only after commentary completion is pending,
/// the turn is still running, and all stream queues have drained.
///
/// This gate prevents flicker while normal output is still actively
/// streaming, but still restores a visible "working" affordance when a
/// commentary block ends before the turn itself has completed.
fn maybe_restore_status_indicator_after_stream_idle(&mut self) {
if !self.pending_status_indicator_restore
|| !self.bottom_pane.is_task_running()
|| !self.stream_controllers_idle()
{
return;
}
self.bottom_pane.ensure_status_indicator();
self.set_status_header(self.current_status_header.clone());
self.pending_status_indicator_restore = false;
}
/// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
@@ -1181,21 +1221,29 @@ impl ChatWidget {
} else {
text
};
// Plan commit ticks can hide the status row; remember whether we streamed plan output so
// completion can restore it once stream queues are idle.
let should_restore_after_stream = self.plan_stream_controller.is_some();
self.plan_delta_buffer.clear();
self.plan_item_active = false;
self.saw_plan_item_this_turn = true;
if let Some(mut controller) = self.plan_stream_controller.take()
&& let Some(cell) = controller.finalize()
{
let finalized_streamed_cell =
if let Some(mut controller) = self.plan_stream_controller.take() {
controller.finalize()
} else {
None
};
if let Some(cell) = finalized_streamed_cell {
self.add_boxed_history(cell);
// TODO: Replace streamed output with the final plan item text if plan streaming is
// removed or if we need to reconcile mismatches between streamed and final content.
return;
} else if !plan_text.is_empty() {
self.add_to_history(history_cell::new_proposed_plan(plan_text));
}
if plan_text.is_empty() {
return;
if should_restore_after_stream {
self.pending_status_indicator_restore = true;
self.maybe_restore_status_indicator_after_stream_idle();
}
self.add_to_history(history_cell::new_proposed_plan(plan_text));
}
fn on_agent_reasoning_delta(&mut self, delta: String) {
@@ -1256,6 +1304,7 @@ impl ChatWidget {
self.quit_shortcut_key = None;
self.update_task_running_state();
self.retry_status_header = None;
self.pending_status_indicator_restore = false;
self.bottom_pane.set_interrupt_hint_visible(true);
self.set_status_header(String::from("Working"));
self.full_reasoning_buffer.clear();
@@ -1297,6 +1346,7 @@ impl ChatWidget {
self.request_status_line_branch_refresh();
}
// Mark task stopped and request redraw now that all content is in history.
self.pending_status_indicator_restore = false;
self.agent_turn_running = false;
self.update_task_running_state();
self.running_commands.clear();
@@ -1528,6 +1578,7 @@ impl ChatWidget {
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
self.pending_status_indicator_restore = false;
self.request_status_line_branch_refresh();
self.maybe_show_pending_rate_limit_prompt();
}
@@ -2087,9 +2138,24 @@ impl ChatWidget {
if self.retry_status_header.is_none() {
self.retry_status_header = Some(self.current_status_header.clone());
}
self.bottom_pane.ensure_status_indicator();
self.set_status(message, additional_details);
}
/// Handle completion of an `AgentMessage` turn item.
///
/// Commentary completion sets a deferred restore flag so the status row
/// returns once stream queues are idle. Final-answer completion (or absent
/// phase for legacy models) clears the flag to preserve historical behavior.
fn on_agent_message_item_completed(&mut self, item: AgentMessageItem) {
self.pending_status_indicator_restore = match item.phase {
// Models that don't support preambles only output AgentMessageItems on turn completion.
Some(MessagePhase::FinalAnswer) | None => false,
Some(MessagePhase::Commentary) => true,
};
self.maybe_restore_status_indicator_after_stream_idle();
}
/// Periodic tick for stream commits. In smooth mode this preserves one-line pacing, while
/// catch-up mode drains larger batches to reduce queue lag.
pub(crate) fn on_commit_tick(&mut self) {
@@ -2110,9 +2176,8 @@ impl ChatWidget {
///
/// `scope` controls whether this call may commit in smooth mode or only when catch-up
/// is currently active. While lines are actively streaming we hide the status row to avoid
/// duplicate "in progress" affordances, but once all stream controllers go idle for this
/// turn we restore the status row if the task is still running so users keep a live
/// spinner/shimmer signal between preamble output and subsequent tool activity.
/// duplicate "in progress" affordances. Restoration is gated separately so we only re-show
/// the row after commentary completion once stream queues are idle.
fn run_commit_tick_with_scope(&mut self, scope: CommitTickScope) {
let now = Instant::now();
let outcome = run_commit_tick(
@@ -2128,10 +2193,7 @@ impl ChatWidget {
}
if outcome.has_controller && outcome.all_idle {
if self.bottom_pane.is_task_running() {
self.bottom_pane.ensure_status_indicator();
self.set_status_header(self.current_status_header.clone());
}
self.maybe_restore_status_indicator_after_stream_idle();
self.app_event_tx.send(AppEvent::StopCommitAnimation);
}
@@ -2562,6 +2624,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status_header: String::from("Working"),
retry_status_header: None,
pending_status_indicator_restore: false,
thread_id: None,
thread_name: None,
forked_from: None,
@@ -2724,6 +2787,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status_header: String::from("Working"),
retry_status_header: None,
pending_status_indicator_restore: false,
thread_id: None,
thread_name: None,
forked_from: None,
@@ -2875,6 +2939,7 @@ impl ChatWidget {
full_reasoning_buffer: String::new(),
current_status_header: String::from("Working"),
retry_status_header: None,
pending_status_indicator_restore: false,
thread_id: None,
thread_name: None,
forked_from: None,
@@ -3967,8 +4032,12 @@ impl ChatWidget {
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::DynamicToolCallRequest(_) => {}
EventMsg::ItemCompleted(event) => {
if let codex_protocol::items::TurnItem::Plan(plan_item) = event.item {
self.on_plan_item_completed(plan_item.text);
let item = event.item;
if let codex_protocol::items::TurnItem::Plan(plan_item) = &item {
self.on_plan_item_completed(plan_item.text.clone());
}
if let codex_protocol::items::TurnItem::AgentMessage(item) = item {
self.on_agent_message_item_completed(item);
}
}
}