Show live context compaction status in the TUI (#42319)

## Why

Context compaction was only reported after it completed, so the TUI did not
show when compaction was actively making room to continue.

## What changed

- Show `Compacting context` as soon as manual or server-driven compaction
  starts, with an elapsed timer independent of the current turn timer.
- Preserve the compaction status across follow-up input and transient activity,
  while still allowing retry errors to take precedence.
- Restore in-progress compaction timing from thread snapshots and add the live
  duration to the completion message.
- Clear stale compaction state when the turn starts, completes, or terminates.

## Testing

Added TUI tests covering manual and server-driven compaction, narrow rendering,
follow-up input, replay and task switching, retry status, timer preservation,
and turn termination.

GitOrigin-RevId: 804e1bc553315da24ff1f99462e8e282818cada3
This commit is contained in:
Eric Traut
2026-09-02 18:03:55 +00:00
committed by copyberry
parent a94a5db629
commit a526f54b00
17 changed files with 392 additions and 8 deletions
+5
View File
@@ -567,6 +567,11 @@ impl BottomPane {
self.status_timer.reset(elapsed);
}
pub(crate) fn set_status_timer_origin(&mut self, started_at: Option<Instant>) {
self.status_timer.display_started_at = started_at;
self.request_redraw();
}
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
self.composer.skills()
}
+1
View File
@@ -395,6 +395,7 @@ mod permissions_menu;
pub(crate) use self::permissions_menu::auto_review_available;
pub(crate) use self::permissions_menu::cyber_model_approval_reviewer;
mod backend_banners;
mod compaction;
mod protocol;
mod protocol_requests;
mod rate_limits;
+57
View File
@@ -0,0 +1,57 @@
//! Live compaction status. Its wall clock is separate from the turn's running time,
//! and only a matching live completion contributes a duration to the transcript.
use super::*;
pub(super) const COMPACTION_HEADER: &str = "Compacting context";
pub(super) const COMPACTION_DETAILS: &str = "Making room to continue.";
#[derive(Debug)]
pub(super) struct ActiveCompaction {
pub(super) id: String,
pub(super) started_at: Instant,
}
impl ChatWidget {
pub(super) fn on_context_compaction_started(&mut self, id: String, elapsed: Duration) {
if self
.status_state
.compaction
.as_ref()
.is_some_and(|active| active.id == id)
{
return;
}
self.flush_answer_stream_with_separator();
let now = Instant::now();
let started_at = now.checked_sub(elapsed).unwrap_or(now);
self.status_state.compaction = Some(ActiveCompaction { id, started_at });
self.bottom_pane.set_status_timer_origin(Some(started_at));
self.bottom_pane.ensure_status_indicator();
self.set_status_header(COMPACTION_HEADER.to_string());
}
pub(super) fn clear_context_compaction(&mut self) {
if self.status_state.compaction.take().is_some() {
self.bottom_pane
.set_status_timer_origin(/*started_at*/ None);
self.set_status_header("Working".to_string());
}
}
pub(super) fn on_context_compaction_completed(&mut self, id: &str, from_replay: bool) {
let mut message = "Context compacted".to_string();
if let Some(active) = self.status_state.compaction.as_ref()
&& active.id == id
{
if !from_replay {
let elapsed = crate::status_indicator_widget::fmt_elapsed_compact(
active.started_at.elapsed().as_secs(),
);
message = format!("Context compacted · {elapsed}");
}
self.clear_context_compaction();
}
self.add_info_message(message, /*hint*/ None);
}
}
+18 -3
View File
@@ -70,7 +70,7 @@ impl ChatWidget {
self.handle_turn_completed_notification(notification, replay_kind);
}
ServerNotification::ItemStarted(notification) => {
self.handle_item_started_notification(notification, replay_kind.is_some());
self.handle_item_started_notification(notification, replay_kind);
}
ServerNotification::ItemCompleted(notification) => {
self.handle_item_completed_notification(notification, replay_kind);
@@ -352,9 +352,24 @@ impl ChatWidget {
fn handle_item_started_notification(
&mut self,
notification: ItemStartedNotification,
from_replay: bool,
replay_kind: Option<ReplayKind>,
) {
match notification.item {
ThreadItem::ContextCompaction { id }
if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) =>
{
// Buffered starts reconstruct an in-flight compaction when switching tasks.
let elapsed = if replay_kind == Some(ReplayKind::ThreadSnapshot) {
let elapsed_ms = chrono::Utc::now()
.timestamp_millis()
.saturating_sub(notification.started_at_ms)
.max(0);
Duration::from_millis(elapsed_ms as u64)
} else {
Duration::ZERO
};
self.on_context_compaction_started(id, elapsed);
}
item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item),
ThreadItem::FileChange { id: _, changes, .. } => {
self.on_patch_apply_begin(file_update_changes_to_display(changes));
@@ -387,7 +402,7 @@ impl ChatWidget {
reasoning_effort,
agents_states,
}),
ThreadItem::EnteredReviewMode { review, .. } if !from_replay => {
ThreadItem::EnteredReviewMode { review, .. } if replay_kind.is_none() => {
self.enter_review_mode_with_hint(review, /*from_replay*/ false);
}
_ => {}
+2 -2
View File
@@ -198,8 +198,8 @@ impl ChatWidget {
ThreadItem::ExitedReviewMode { .. } => {
self.exit_review_mode_after_item();
}
ThreadItem::ContextCompaction { .. } => {
self.add_info_message("Context compacted".to_string(), /*hint*/ None);
ThreadItem::ContextCompaction { id } => {
self.on_context_compaction_completed(&id, from_replay);
}
ThreadItem::FunctionCallOutput {
name,
@@ -269,6 +269,13 @@ impl ChatWidget {
if !self.bottom_pane.is_task_running() {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.bottom_pane.ensure_status_indicator();
self.set_status(
compaction::COMPACTION_HEADER.to_string(),
Some(compaction::COMPACTION_DETAILS.to_string()),
StatusDetailsCapitalization::Preserve,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
self.input_queue.user_turn_pending_start = true;
self.app_event_tx.compact();
}
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/compaction_tests.rs
expression: normalize_compaction_snapshot(lines_to_single_string(&lines))
---
• Context compacted · <elapsed>
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/compaction_tests.rs
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))"
---
• Compacting context (<elapsed> • esc to interrupt)
└ Making room to continue.
› Ask Codex to do anything
gpt-5.6-sol default · /tmp/project
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/compaction_tests.rs
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 40))"
---
• Compacting context (<elapsed> • esc to i…
└ Making room to continue.
› Ask Codex to do anything
gpt-5.6-sol default · /tmp/project
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/compaction_tests.rs
expression: "normalize_compaction_snapshot(render_bottom_popup(&chat, 80))"
---
• Compacting context (<elapsed> • esc to interrupt)
└ Making room to continue.
› Ask Codex to do anything
gpt-5.6-sol default · /tmp/project
@@ -18,6 +18,19 @@ impl ChatWidget {
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) -> bool {
// Follow-up input and background activity must not obscure compaction.
// Retry errors still get their own status until the next notification.
let (header, details, details_max_lines) = if self.status_state.compaction.is_some()
&& self.status_state.retry_status_header.is_none()
{
(
compaction::COMPACTION_HEADER.to_string(),
Some(compaction::COMPACTION_DETAILS.to_string()),
STATUS_DETAILS_DEFAULT_MAX_LINES,
)
} else {
(header, details, details_max_lines)
};
let details = details
.filter(|details| !details.is_empty())
.map(|details| {
@@ -110,6 +110,7 @@ impl PendingGuardianReviewStatus {
#[derive(Debug)]
pub(super) struct StatusState {
pub(super) compaction: Option<super::compaction::ActiveCompaction>,
pub(super) current_status: StatusIndicatorState,
pub(super) pending_guardian_review_status: PendingGuardianReviewStatus,
pub(super) terminal_title_status_kind: TerminalTitleStatusKind,
@@ -120,6 +121,7 @@ pub(super) struct StatusState {
impl Default for StatusState {
fn default() -> Self {
Self {
compaction: None,
current_status: StatusIndicatorState::working(),
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
+2
View File
@@ -235,6 +235,8 @@ mod app_server;
mod approval_requests;
#[path = "tests/backend_banners_tests.rs"]
mod backend_banners_tests;
#[path = "tests/compaction_tests.rs"]
mod compaction_tests;
mod composer_submission;
#[path = "tests/config_errors_tests.rs"]
mod config_errors;
@@ -0,0 +1,229 @@
use super::*;
use pretty_assertions::assert_eq;
fn normalize_compaction_snapshot(text: String) -> String {
let elapsed = regex_lite::Regex::new(r"\b\d+(?:h \d+m \d+s|m \d+s|s)\b").unwrap();
elapsed
.replace_all(&normalize_snapshot_paths(text), "<elapsed>")
.into_owned()
}
fn compaction_started(id: &str) -> ServerNotification {
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: chrono::Utc::now().timestamp_millis(),
item: AppServerThreadItem::ContextCompaction { id: id.to_string() },
})
}
fn compaction_completed(id: &str) -> ServerNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: AppServerThreadItem::ContextCompaction { id: id.to_string() },
})
}
#[tokio::test]
async fn compaction_status_survives_follow_up_and_preserves_turn_time() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
chat.bottom_pane
.reset_status_timer(Duration::from_secs(/*secs*/ 600));
chat.on_agent_message_delta("Previous commentary\n".to_string());
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
chat.on_commit_tick();
assert!(chat.bottom_pane.status_indicator_visible());
let started_at = Instant::now() - Duration::from_secs(/*secs*/ 83);
chat.status_state.compaction.as_mut().unwrap().started_at = started_at;
chat.bottom_pane.set_status_timer_origin(Some(started_at));
// Repeated start notifications must not restart the displayed timer.
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
assert_eq!(
chat.status_state.compaction.as_ref().unwrap().started_at,
started_at
);
assert_chatwidget_snapshot!(
"compaction_running",
normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 80))
);
assert_chatwidget_snapshot!(
"compaction_running_narrow",
normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 40))
);
chat.handle_composer_input_result(
InputResult::Submitted {
text: "keep going".to_string(),
text_elements: Vec::new(),
},
/*had_modal_or_popup*/ false,
);
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
assert_eq!(chat.input_queue.pending_steers.len(), 1);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Compacting context"
);
drain_insert_history(&mut rx);
chat.handle_server_notification(compaction_completed("compact-1"), /*replay_kind*/ None);
let history = drain_insert_history(&mut rx);
let lines: Vec<_> = history.into_iter().flatten().collect();
assert_chatwidget_snapshot!(
"compaction_completed",
normalize_compaction_snapshot(lines_to_single_string(&lines))
);
assert!(chat.status_state.compaction.is_none());
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Working"
);
assert!(chat.bottom_pane.status_elapsed().unwrap() >= Duration::from_secs(/*secs*/ 600));
}
#[tokio::test]
async fn manual_compaction_shows_status_before_backend_events() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Compact);
assert_chatwidget_snapshot!(
"manual_compaction_pending",
normalize_compaction_snapshot(render_bottom_popup(&chat, /*width*/ 80))
);
assert!(chat.handle_turn_start_rejection("Could not start compaction".to_string()));
assert!(!chat.bottom_pane.status_indicator_visible());
}
#[tokio::test]
async fn compaction_status_clears_when_turn_ends_without_item_completion() {
for status in [
AppServerTurnStatus::Completed,
AppServerTurnStatus::Interrupted,
AppServerTurnStatus::Failed,
] {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
chat.handle_server_notification(
ServerNotification::TurnCompleted(TurnCompletedNotification {
thread_id: "thread-1".to_string(),
turn: app_server_turn(
"turn-1", status, /*duration_ms*/ None, /*error*/ None,
),
}),
/*replay_kind*/ None,
);
assert!(chat.status_state.compaction.is_none());
assert!(!chat.bottom_pane.status_indicator_visible());
let history = drain_insert_history(&mut rx);
let lines: Vec<_> = history.into_iter().flatten().collect();
assert!(!lines_to_single_string(&lines).contains("Context compacted"));
handle_turn_started(&mut chat, "turn-2");
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Working"
);
}
}
#[tokio::test]
async fn compaction_history_does_not_start_a_timer_or_finish_live_compaction() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
compaction_started("old"),
Some(ReplayKind::ResumeInitialMessages),
);
assert!(chat.status_state.compaction.is_none());
assert!(!chat.bottom_pane.status_indicator_visible());
handle_turn_started(&mut chat, "turn-1");
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
chat.handle_server_notification(
compaction_completed("old"),
Some(ReplayKind::ThreadSnapshot),
);
assert_eq!(
chat.status_state.compaction.as_ref().unwrap().id,
"compact-1"
);
let history = drain_insert_history(&mut rx);
let lines: Vec<_> = history.into_iter().flatten().collect();
assert_eq!(lines_to_single_string(&lines).trim(), "• Context compacted");
}
#[tokio::test]
async fn compaction_snapshot_restores_elapsed_time_and_clears_on_replayed_completion() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
let ServerNotification::ItemStarted(mut started) = compaction_started("compact-1") else {
unreachable!();
};
started.started_at_ms -= 12_000;
chat.handle_server_notification(
ServerNotification::ItemStarted(started),
Some(ReplayKind::ThreadSnapshot),
);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Compacting context"
);
assert!(
chat.status_state
.compaction
.as_ref()
.unwrap()
.started_at
.elapsed()
>= Duration::from_secs(/*secs*/ 12)
);
chat.handle_server_notification(
compaction_completed("compact-1"),
Some(ReplayKind::ThreadSnapshot),
);
assert!(chat.status_state.compaction.is_none());
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Working"
);
let lines: Vec<_> = drain_insert_history(&mut rx)
.into_iter()
.flatten()
.collect();
assert_eq!(lines_to_single_string(&lines).trim(), "• Context compacted");
}
#[tokio::test]
async fn compaction_retry_status_returns_to_compacting() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
will_retry: true,
error: codex_app_server_protocol::TurnError {
message: "Reconnecting".to_string(),
codex_error_info: None,
additional_details: None,
misalignment: None,
},
}),
/*replay_kind*/ None,
);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Reconnecting"
);
chat.handle_server_notification(compaction_started("compact-1"), /*replay_kind*/ None);
assert_eq!(
chat.bottom_pane.status_widget().unwrap().header(),
"Compacting context"
);
}
@@ -68,6 +68,7 @@ impl ChatWidget {
// Raw reasoning uses the same flow as summarized reasoning
pub(super) fn on_task_started(&mut self) {
self.clear_context_compaction();
self.input_queue.user_turn_pending_start = false;
self.reset_safety_buffering_for_turn_start();
self.turn_lifecycle.start(Instant::now());
@@ -176,6 +177,7 @@ impl ChatWidget {
self.refresh_thread_usage_after_turn();
}
// 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.input_queue.user_turn_pending_start = false;
self.clear_active_hook_cell();
@@ -313,6 +315,7 @@ 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) {
self.clear_context_compaction();
self.clear_safety_buffering();
// Drop preview-only stream tail content on any termination path before
// failed-cell finalization, so transient tail cells are never persisted.
+11 -3
View File
@@ -211,7 +211,10 @@ impl StatusIndicator<'_> {
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let row = self.row;
let now = Instant::now();
let elapsed_duration = self.timer.elapsed_at(now);
let elapsed_duration = self.timer.display_started_at.map_or_else(
|| self.timer.elapsed_at(now),
|started_at| now.saturating_duration_since(started_at),
);
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled);
@@ -280,10 +283,15 @@ impl Renderable for StatusIndicator<'_> {
if area.is_empty() {
return;
}
if self.row.animations_enabled {
if self.row.animations_enabled || self.timer.display_started_at.is_some() {
let interval_ms = if self.row.animations_enabled {
32
} else {
1_000
};
self.row
.frame_requester
.schedule_frame_in(Duration::from_millis(32));
.schedule_frame_in(Duration::from_millis(interval_ms));
}
Paragraph::new(Text::from(self.lines(area.width))).render(area, buf);
}
@@ -1,10 +1,13 @@
//! Pause-aware status time owned independently of the optional status row.
//! A phase can override the displayed clock without changing turn accounting.
use std::time::Duration;
use std::time::Instant;
#[derive(Debug)]
pub(crate) struct StatusTimer {
/// Optional wall-clock origin for the displayed phase, without resetting turn accounting.
pub(crate) display_started_at: Option<Instant>,
pub(super) elapsed_running: Duration,
pub(super) last_resume_at: Instant,
pub(super) is_paused: bool,
@@ -13,6 +16,7 @@ pub(crate) struct StatusTimer {
impl Default for StatusTimer {
fn default() -> Self {
Self {
display_started_at: None,
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,