diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd1014b2f4..0c03d796d2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -59,6 +59,8 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandOutputDeltaEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; +use codex_core::protocol::ItemCompletedEvent; +use codex_core::protocol::ItemStartedEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; @@ -101,6 +103,7 @@ 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::TurnItem; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -3055,6 +3058,8 @@ impl ChatWidget { force_reload: true, }); } + EventMsg::ItemStarted(ev) => self.on_item_started(ev, from_replay), + EventMsg::ItemCompleted(ev) => self.on_item_completed(ev, from_replay), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), @@ -3077,7 +3082,7 @@ impl ChatWidget { self.on_entered_review_mode(review_request, from_replay) } EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review), - EventMsg::ContextCompacted(_) => self.on_agent_message("Context compacted".to_owned()), + EventMsg::ContextCompacted(_) => {} EventMsg::CollabAgentSpawnBegin(_) => {} EventMsg::CollabAgentSpawnEnd(ev) => self.on_collab_event(collab::spawn_end(ev)), EventMsg::CollabAgentInteractionBegin(_) => {} @@ -3090,8 +3095,6 @@ impl ChatWidget { EventMsg::CollabCloseEnd(ev) => self.on_collab_event(collab::close_end(ev)), EventMsg::ThreadRolledBack(_) => {} EventMsg::RawResponseItem(_) - | EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) @@ -3099,6 +3102,59 @@ impl ChatWidget { } } + fn on_item_started(&mut self, event: ItemStartedEvent, from_replay: bool) { + if from_replay { + return; + } + let TurnItem::ContextCompaction(item) = event.item else { + return; + }; + + self.flush_answer_stream_with_separator(); + if let Some(cell) = self.active_cell.as_mut().and_then(|cell| { + cell.as_any_mut() + .downcast_mut::() + }) && cell.item_id() == item.id + { + self.bump_active_cell_revision(); + self.request_redraw(); + return; + } + + self.flush_active_cell(); + self.active_cell = Some(Box::new(history_cell::new_active_context_compaction( + item.id, + self.config.animations, + ))); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn on_item_completed(&mut self, event: ItemCompletedEvent, from_replay: bool) { + if from_replay { + return; + } + let TurnItem::ContextCompaction(item) = event.item else { + return; + }; + + self.flush_answer_stream_with_separator(); + if let Some(cell) = self.active_cell.as_mut().and_then(|cell| { + cell.as_any_mut() + .downcast_mut::() + }) && cell.item_id() == item.id + { + cell.complete(); + self.bump_active_cell_revision(); + self.flush_active_cell(); + self.request_redraw(); + return; + } + + self.add_to_history(history_cell::new_context_compaction_completed(item.id)); + self.request_redraw(); + } + fn on_entered_review_mode(&mut self, review: ReviewRequest, from_replay: bool) { // Enter review mode and emit a concise banner if self.pre_review_token_info.is_none() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 64060aece8..a508a0ee51 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -29,6 +29,7 @@ use codex_core::protocol::AgentReasoningDeltaEvent; use codex_core::protocol::AgentReasoningEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::BackgroundEventEvent; +use codex_core::protocol::ContextCompactedEvent; use codex_core::protocol::CreditsSnapshot; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; @@ -39,6 +40,8 @@ use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::ItemCompletedEvent; +use codex_core::protocol::ItemStartedEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; use codex_core::protocol::McpStartupUpdateEvent; @@ -67,6 +70,8 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; +use codex_protocol::items::ContextCompactionItem; +use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -4480,6 +4485,58 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn compaction_items_drive_history_and_context_compacted_is_ignored() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let thread_id = ThreadId::new(); + let compaction_item = ContextCompactionItem::new(); + let turn_id = "turn-1".to_string(); + + chat.handle_codex_event(Event { + id: "item-start".into(), + msg: EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.clone(), + item: TurnItem::ContextCompaction(compaction_item.clone()), + }), + }); + let started_cells = drain_insert_history(&mut rx); + assert!( + started_cells.is_empty(), + "compaction start should keep an active cell instead of flushing history" + ); + chat.handle_codex_event(Event { + id: "context-compacted".into(), + msg: EventMsg::ContextCompacted(ContextCompactedEvent {}), + }); + let compacted_cells = drain_insert_history(&mut rx); + assert!( + compacted_cells.is_empty(), + "ContextCompacted should not emit history cells" + ); + chat.handle_codex_event(Event { + id: "item-end".into(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id, + item: TurnItem::ContextCompaction(compaction_item), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected a single finalized compaction cell" + ); + + let completed = lines_to_single_string(&cells[0]); + assert!( + completed.contains("Context compacted."), + "compaction completion message missing: {completed}" + ); +} + #[tokio::test] async fn stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 687d18fa6a..e229f49295 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1429,6 +1429,76 @@ pub(crate) fn new_web_search_call( cell } +fn context_compaction_header(completed: bool) -> &'static str { + if completed { + "Context compacted." + } else { + "Compacting context" + } +} + +#[derive(Debug)] +pub(crate) struct ContextCompactionCell { + item_id: String, + start_time: Instant, + completed: bool, + animations_enabled: bool, +} + +impl ContextCompactionCell { + pub(crate) fn new(item_id: String, animations_enabled: bool) -> Self { + Self { + item_id, + start_time: Instant::now(), + completed: false, + animations_enabled, + } + } + + pub(crate) fn item_id(&self) -> &str { + &self.item_id + } + + pub(crate) fn complete(&mut self) { + self.completed = true; + } +} + +impl HistoryCell for ContextCompactionCell { + fn display_lines(&self, width: u16) -> Vec> { + let bullet = if self.completed { + Line::from(vec!["• ".dim()]) + } else { + Line::from(vec![ + spinner(Some(self.start_time), self.animations_enabled), + " ".into(), + ]) + }; + let header = context_compaction_header(self.completed); + PrefixedWrappedHistoryCell::new(header.bold(), bullet, " ").display_lines(width) + } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled || self.completed { + return None; + } + Some((self.start_time.elapsed().as_millis() / 50) as u64) + } +} + +pub(crate) fn new_active_context_compaction( + item_id: String, + animations_enabled: bool, +) -> ContextCompactionCell { + ContextCompactionCell::new(item_id, animations_enabled) +} + +pub(crate) fn new_context_compaction_completed(item_id: String) -> ContextCompactionCell { + let mut cell = ContextCompactionCell::new(item_id, false); + cell.complete(); + cell +} + /// Returns an additional history cell if an MCP tool result includes a decodable image. /// /// This intentionally returns at most one cell: the first image in `CallToolResult.content` that