diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 85a9c3a8f371..159c65f011e4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -123,7 +123,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; -use codex_chatgpt::connectors; +use codex_chatgpt::connectors as chatgpt_connectors; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; @@ -169,7 +169,6 @@ use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -316,6 +315,9 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; +mod connectors; +use self::connectors::ConnectorsCacheState; +use self::connectors::ConnectorsState; mod goal_status; use self::goal_status::GoalStatusState; #[cfg(test)] @@ -324,6 +326,8 @@ mod goal_menu; mod goal_validation; mod ide_context; use self::ide_context::IdeContextState; +mod input_queue; +use self::input_queue::InputQueueState; mod interrupts; use self::interrupts::InterruptManager; mod keymap_picker; @@ -341,14 +345,24 @@ mod plugins; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod protocol; mod realtime; use self::realtime::RealtimeConversationUiState; mod reasoning_shortcuts; +mod review; +use self::review::ReviewState; mod service_tiers; mod side; +mod status_state; +use self::status_state::StatusIndicatorState; +use self::status_state::StatusState; +use self::status_state::TerminalTitleStatusKind; mod status_surfaces; use self::status_surfaces::CachedProjectRootName; -use self::status_surfaces::TerminalTitleStatusKind; +mod transcript; +use self::transcript::TranscriptState; +mod turn_lifecycle; +use self::turn_lifecycle::TurnLifecycleState; mod user_messages; use self::user_messages::PendingSteerCompareKey; use self::user_messages::UserMessageDisplay; @@ -463,12 +477,6 @@ const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; const MAX_AGENT_COPY_HISTORY: usize = 32; -#[derive(Debug)] -struct AgentTurnMarkdown { - user_turn_count: usize, - markdown: String, -} - #[derive(Default)] struct RateLimitWarningState { secondary_index: usize, @@ -593,15 +601,6 @@ enum RateLimitSwitchPromptState { Shown, } -#[derive(Debug, Clone, Default)] -enum ConnectorsCacheState { - #[default] - Uninitialized, - Loading, - Ready(ConnectorsSnapshot), - Failed(String), -} - #[derive(Debug, Clone, Default)] struct PluginListFetchState { cache_cwd: Option, @@ -644,95 +643,6 @@ pub(crate) enum ExternalEditorState { Active, } -#[derive(Clone, Debug, PartialEq, Eq)] -struct StatusIndicatorState { - header: String, - details: Option, - details_max_lines: usize, -} - -impl StatusIndicatorState { - fn working() -> Self { - Self { - header: String::from("Working"), - details: None, - details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, - } - } - - fn is_guardian_review(&self) -> bool { - self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ") - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct PendingGuardianReviewStatus { - entries: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PendingGuardianReviewStatusEntry { - id: String, - detail: String, -} - -impl PendingGuardianReviewStatus { - fn start_or_update(&mut self, id: String, detail: String) { - if let Some(existing) = self.entries.iter_mut().find(|entry| entry.id == id) { - existing.detail = detail; - } else { - self.entries - .push(PendingGuardianReviewStatusEntry { id, detail }); - } - } - - fn finish(&mut self, id: &str) -> bool { - let original_len = self.entries.len(); - self.entries.retain(|entry| entry.id != id); - self.entries.len() != original_len - } - - fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - // Guardian review status is derived from the full set of currently pending - // review entries. The generic status cache on `ChatWidget` stores whichever - // footer is currently rendered; this helper computes the guardian-specific - // footer snapshot that should replace it while reviews remain in flight. - fn status_indicator_state(&self) -> Option { - let details = if self.entries.len() == 1 { - self.entries.first().map(|entry| entry.detail.clone()) - } else if self.entries.is_empty() { - None - } else { - let mut lines = self - .entries - .iter() - .take(3) - .map(|entry| format!("• {}", entry.detail)) - .collect::>(); - let remaining = self.entries.len().saturating_sub(3); - if remaining > 0 { - lines.push(format!("+{remaining} more")); - } - Some(lines.join("\n")) - }; - let details = details?; - let header = if self.entries.len() == 1 { - String::from("Reviewing approval request") - } else { - format!("Reviewing {} approval requests", self.entries.len()) - }; - let details_max_lines = if self.entries.len() == 1 { 1 } else { 4 }; - Some(StatusIndicatorState { - header, - details: Some(details), - details_max_lines, - }) - } -} - /// Maintains the per-session UI state and interaction state machines for the chat screen. /// /// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming @@ -749,17 +659,7 @@ pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_target: CodexOpTarget, bottom_pane: BottomPane, - active_cell: Option>, - /// Monotonic-ish counter used to invalidate transcript overlay caching. - /// - /// The transcript overlay appends a cached "live tail" for the current active cell. Most - /// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer - /// identity alone is not a good cache key. - /// - /// Callers bump this whenever the active cell's transcript output could change without - /// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision - /// where the overlay may briefly treat new tail content as already cached. - active_cell_revision: u64, + transcript: TranscriptState, config: Config, environment_manager: Arc, raw_output_mode: bool, @@ -796,29 +696,6 @@ pub(crate) struct ChatWidget { /// Holds the platform clipboard lease so copied text remains available while supported. clipboard_lease: Option, copy_last_response_binding: Vec, - /// Raw markdown of the most recently completed agent response that - /// survived any local thread rollback. - last_agent_markdown: Option, - /// Copyable agent responses keyed by the number of visible user turns at - /// the time the response completed. - agent_turn_markdowns: Vec, - /// Number of user turns currently reflected in the visible transcript. - visible_user_turn_count: usize, - /// True when rollback discarded the requested copy source because it was - /// older than the retained copy history. - copy_history_evicted_by_rollback: bool, - /// Raw markdown of the most recently completed proposed plan. - /// - /// This is cached only for the approval popup. It is reset at the start of each new task so the - /// fresh-context action cannot accidentally submit an older plan after a later turn begins. - latest_proposed_plan_markdown: Option, - /// Whether this turn already produced a copyable response. - /// - /// `TurnComplete.last_agent_message` is a fallback source: use it only when no earlier - /// agent/plan/review item recorded copyable markdown for the turn. This gives item-level - /// sources precedence and avoids duplicating the same final answer when both event shapes are - /// emitted. - saw_copy_source_this_turn: bool, running_commands: HashMap, collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, @@ -827,14 +704,9 @@ pub(crate) struct ChatWidget { skills_initial_state: Option>, last_unified_wait: Option, unified_exec_wait_streak: Option, - turn_sleep_inhibitor: SleepInhibitor, + turn_lifecycle: TurnLifecycleState, task_complete_pending: bool, unified_exec_processes: Vec, - /// Tracks whether codex-core currently considers an agent turn to be in progress. - /// - /// This is kept separate from `mcp_startup_status` so that MCP startup progress (or completion) - /// can update the status header without accidentally clearing the spinner for an active turn. - agent_turn_running: bool, /// Tracks per-server MCP startup state while startup is in progress. /// /// The map is `Some(_)` from the first startup status update until the @@ -852,10 +724,7 @@ pub(crate) struct ChatWidget { mcp_startup_pending_next_round: HashMap, /// Tracks whether the buffered next round has seen any `Starting` update yet. mcp_startup_pending_next_round_saw_starting: bool, - connectors_cache: ConnectorsCacheState, - connectors_partial_snapshot: Option, - connectors_prefetch_in_flight: bool, - connectors_force_refetch_pending: bool, + connectors: ConnectorsState, ide_context: IdeContextState, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, @@ -869,31 +738,16 @@ pub(crate) struct ChatWidget { reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, - // The currently rendered footer state. We keep the already-formatted - // details here so transient stream interruptions can restore the footer - // exactly as it was shown. - current_status: StatusIndicatorState, - // Guardian review keeps its own pending set so it can derive a single - // footer summary from one or more in-flight review events. - pending_guardian_review_status: PendingGuardianReviewStatus, - recent_auto_review_denials: RecentAutoReviewDenials, + status_state: StatusState, + review: ReviewState, // Active hook runs render in a dedicated live cell so they can run alongside tools. active_hook_cell: Option, - // Semantic status used for terminal-title status rendering. - terminal_title_status_kind: TerminalTitleStatusKind, - // Previous status header to restore after a transient stream retry. - retry_status_header: Option, - // Set when commentary output completes; once stream queues go idle we restore the status row. - pending_status_indicator_restore: bool, - suppress_queue_autosend: bool, thread_id: Option, /// Nudge dismissals that should survive draft edits within the current thread scope. /// /// The nudge is only a discovery aid, so once a user dismisses it or enters Plan mode we keep it /// hidden for that thread instead of resurfacing it on every matching draft. dismissed_plan_mode_nudge_scopes: HashSet, - last_turn_id: Option, - budget_limited_turn_ids: HashSet, thread_name: Option, thread_rename_block_message: Option, active_side_conversation: bool, @@ -913,30 +767,7 @@ pub(crate) struct ChatWidget { // history has been rendered so resumed/forked prompts keep chronological // order. suppress_initial_user_message_submit: bool, - // User inputs queued while a turn is in progress. - queued_user_messages: VecDeque, - // History records for queued user messages. Slash commands such as `/goal` - // can render history that differs from the text submitted to core, so this - // stays in lockstep with `queued_user_messages`, with missing entries - // treated as user-message text. - queued_user_message_history_records: VecDeque, - // A user turn has been submitted to core, but `TurnStarted` has not arrived yet. - user_turn_pending_start: bool, - // User messages that tried to steer a non-regular turn and must be retried first. - rejected_steers_queue: VecDeque, - // History records for rejected steers. Slash commands such as `/goal` can - // render history that differs from the text submitted to core, so this stays - // in lockstep with `rejected_steers_queue`, with missing entries treated as - // user-message text. - rejected_steer_history_records: VecDeque, - // Steers already submitted to core but not yet committed into history. - // - // The bottom pane shows these above queued drafts until core records the - // corresponding user message item. - pending_steers: VecDeque, - // When set, the next interrupt should resubmit all pending steers as one - // fresh user turn instead of restoring them into the composer. - submit_pending_steers_after_interrupt: bool, + input_queue: InputQueueState, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, /// Keybinding to show for popping the most-recently queued message back @@ -953,32 +784,6 @@ pub(crate) struct ChatWidget { /// We require the second press to match this key so `Ctrl+C` followed by /// `Ctrl+D` (or vice versa) doesn't quit accidentally. quit_shortcut_key: Option, - // Simple review mode flag; used to adjust layout and banners. - is_review_mode: bool, - // Snapshot of token usage to restore after review mode exits. - pre_review_token_info: Option>, - // Whether the next streamed assistant content should be preceded by a final message separator. - // - // This is set whenever we insert a visible history cell that conceptually belongs to a turn. - // The separator itself is only rendered if the turn recorded "work" activity. - needs_final_message_separator: bool, - // Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). - // - // This gates rendering of the "Worked for …" separator so purely conversational turns don't - // show an empty divider. - had_work_activity: bool, - // Whether the current turn emitted a plan update. - saw_plan_update_this_turn: bool, - // Whether the current turn emitted a proposed plan item that has not been superseded by a - // later steer. This is cleared when the user submits a steer so the plan popup only appears - // if a newer proposed plan arrives afterward. - saw_plan_item_this_turn: bool, - // Latest `update_plan` checklist task counts for terminal-title rendering. - last_plan_progress: Option<(usize, usize)>, - // Incremental buffer for streamed plan content. - plan_delta_buffer: String, - // True while a plan item is streaming. - plan_item_active: bool, // Runtime metrics accumulated across delta snapshots for the active turn. turn_runtime_metrics: RuntimeMetricsSummary, last_rendered_width: std::cell::Cell>, @@ -1033,7 +838,6 @@ pub(crate) struct ChatWidget { // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, - goal_status_active_turn_started_at: Option, external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, last_rendered_user_message_display: Option, @@ -1205,6 +1009,7 @@ impl From<&str> for UserMessage { } } +#[derive(Debug)] struct PendingSteer { user_message: UserMessage, history_record: UserMessageHistoryRecord, @@ -1711,18 +1516,19 @@ impl ChatWidget { /// The bottom pane only has one running flag, but this module treats it as a derived state of /// both the agent turn lifecycle and MCP startup lifecycle. fn update_task_running_state(&mut self) { - self.bottom_pane - .set_task_running(self.agent_turn_running || self.mcp_startup_status.is_some()); + self.bottom_pane.set_task_running( + self.turn_lifecycle.agent_turn_running || self.mcp_startup_status.is_some(), + ); self.refresh_plan_mode_nudge(); self.refresh_status_surfaces(); } fn restore_reasoning_status_header(&mut self) { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else if self.bottom_pane.is_task_running() { - self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); } } @@ -1731,7 +1537,7 @@ impl ChatWidget { let Some(wait) = self.unified_exec_wait_streak.take() else { return; }; - self.needs_final_message_separator = true; + self.transcript.needs_final_message_separator = true; let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new()); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); @@ -1779,7 +1585,7 @@ impl ChatWidget { /// 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 + if !self.status_state.pending_status_indicator_restore || !self.bottom_pane.is_task_running() || !self.stream_controllers_idle() { @@ -1788,12 +1594,12 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.set_status( - self.current_status.header.clone(), - self.current_status.details.clone(), + self.status_state.current_status.header.clone(), + self.status_state.current_status.details.clone(), StatusDetailsCapitalization::Preserve, - self.current_status.details_max_lines, + self.status_state.current_status.details_max_lines, ); - self.pending_status_indicator_restore = false; + self.status_state.pending_status_indicator_restore = false; } /// Update the status indicator header and details. @@ -1817,11 +1623,11 @@ impl ChatWidget { StatusDetailsCapitalization::Preserve => trimmed.to_string(), } }); - self.current_status = StatusIndicatorState { + self.status_state.set_status(StatusIndicatorState { header: header.clone(), details: details.clone(), details_max_lines, - }; + }); self.bottom_pane.update_status( header, details, @@ -2004,38 +1810,20 @@ impl ChatWidget { } fn restore_retry_status_header_if_present(&mut self) { - if let Some(header) = self.retry_status_header.take() { + if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); } } /// Record or update the raw markdown for the current agent turn. fn record_agent_markdown(&mut self, message: &str) { - if message.is_empty() { - return; + if !message.is_empty() { + self.transcript.record_agent_markdown(message.to_string()); } - let markdown = message.to_string(); - match self.agent_turn_markdowns.last_mut() { - Some(entry) if entry.user_turn_count == self.visible_user_turn_count => { - entry.markdown = markdown.clone(); - } - _ => { - self.agent_turn_markdowns.push(AgentTurnMarkdown { - user_turn_count: self.visible_user_turn_count, - markdown: markdown.clone(), - }); - if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { - self.agent_turn_markdowns.remove(0); - } - } - } - self.last_agent_markdown = Some(markdown); - self.copy_history_evicted_by_rollback = false; - self.saw_copy_source_this_turn = true; } fn record_visible_user_turn_for_copy(&mut self) { - self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1); + self.transcript.record_visible_user_turn(); } // --- Small event handlers --- @@ -2045,11 +1833,7 @@ impl ChatWidget { display: SessionConfiguredDisplay, fork_parent_title: Option, ) { - self.last_agent_markdown = None; - self.agent_turn_markdowns.clear(); - self.visible_user_turn_count = 0; - self.copy_history_evicted_by_rollback = false; - self.saw_copy_source_this_turn = false; + self.transcript.reset_copy_history(); let history_metadata = session.message_history.unwrap_or_default(); self.bottom_pane.set_history_metadata( session.thread_id, @@ -2061,15 +1845,13 @@ impl ChatWidget { let previous_thread_id = self.thread_id; self.thread_id = Some(session.thread_id); if previous_thread_id != self.thread_id { - self.recent_auto_review_denials = RecentAutoReviewDenials::default(); + self.review.recent_auto_review_denials = RecentAutoReviewDenials::default(); } self.refresh_plan_mode_nudge(); - self.last_turn_id = None; + self.turn_lifecycle.reset_thread(); self.thread_name = session.thread_name.clone(); self.current_goal_status_indicator = None; self.current_goal_status = None; - self.goal_status_active_turn_started_at = None; - self.budget_limited_turn_ids.clear(); self.update_collaboration_mode_indicator(); self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); @@ -2136,14 +1918,15 @@ impl ChatWidget { ); self.apply_session_info_cell(session_info_cell); } else if self + .transcript .active_cell .as_ref() .is_some_and(|cell| cell.as_any().is::()) { - self.active_cell = None; + self.transcript.active_cell = None; self.bump_active_cell_revision(); } - self.saw_copy_source_this_turn = false; + self.transcript.saw_copy_source_this_turn = false; self.refresh_skills_for_current_cwd(/*force_reload*/ true); if self.connectors_enabled() { self.prefetch_connectors(); @@ -2267,7 +2050,7 @@ impl ChatWidget { ) { let view = crate::bottom_pane::FeedbackNoteView::new( category, - self.last_turn_id.clone(), + self.turn_lifecycle.last_turn_id.clone(), self.app_event_tx.clone(), include_logs, ); @@ -2327,11 +2110,11 @@ impl ChatWidget { if self.active_mode_kind() != ModeKind::Plan { return; } - if !self.plan_item_active { - self.plan_item_active = true; - self.plan_delta_buffer.clear(); + if !self.transcript.plan_item_active { + self.transcript.plan_item_active = true; + self.transcript.plan_delta_buffer.clear(); } - self.plan_delta_buffer.push_str(&delta); + self.transcript.plan_delta_buffer.push_str(&delta); // Before streaming plan content, flush any active exec cell group. self.flush_unified_exec_wait_streak(); self.flush_active_cell(); @@ -2353,7 +2136,7 @@ impl ChatWidget { } fn on_plan_item_completed(&mut self, text: String) { - let streamed_plan = self.plan_delta_buffer.trim().to_string(); + let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string(); let plan_text = if text.trim().is_empty() { streamed_plan } else { @@ -2361,14 +2144,14 @@ impl ChatWidget { }; if !plan_text.trim().is_empty() { self.record_agent_markdown(&plan_text); - self.latest_proposed_plan_markdown = Some(plan_text.clone()); + self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone()); } // 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; + self.transcript.plan_delta_buffer.clear(); + self.transcript.plan_item_active = false; + self.transcript.saw_plan_item_this_turn = true; let (finalized_streamed_cell, consolidated_plan_source) = if let Some(mut controller) = self.plan_stream_controller.take() { controller.finalize() @@ -2390,7 +2173,7 @@ impl ChatWidget { .send(AppEvent::ConsolidateProposedPlan(source)); } if should_restore_after_stream { - self.pending_status_indicator_restore = true; + self.status_state.pending_status_indicator_restore = true; self.maybe_restore_status_indicator_after_stream_idle(); } } @@ -2409,7 +2192,7 @@ impl ChatWidget { if let Some(header) = extract_first_bold(&self.reasoning_buffer) { // Update the shimmer header to the extracted reasoning chunk header. - self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status_header(header); } else { // Fallback while we don't yet have a bold header: leave existing header as-is. @@ -2442,18 +2225,9 @@ impl ChatWidget { // Raw reasoning uses the same flow as summarized reasoning fn on_task_started(&mut self) { - self.user_turn_pending_start = false; - self.agent_turn_running = true; - self.goal_status_active_turn_started_at = Some(Instant::now()); - self.turn_sleep_inhibitor - .set_turn_running(/*turn_running*/ true); - self.saw_copy_source_this_turn = false; - self.saw_plan_update_this_turn = false; - self.saw_plan_item_this_turn = false; - self.had_work_activity = false; - self.latest_proposed_plan_markdown = None; - self.plan_delta_buffer.clear(); - self.plan_item_active = false; + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.start(Instant::now()); + self.transcript.reset_turn_flags(); self.adaptive_chunking.reset(); self.plan_stream_controller = None; self.turn_runtime_metrics = RuntimeMetricsSummary::default(); @@ -2462,14 +2236,14 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.update_task_running_state(); - self.retry_status_header = None; + self.status_state.retry_status_header = None; if self.active_hook_cell.take().is_some() { self.bump_active_cell_revision(); } - self.pending_status_indicator_restore = false; + self.status_state.pending_status_indicator_restore = false; self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); - self.terminal_title_status_kind = TerminalTitleStatusKind::Working; + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); @@ -2482,7 +2256,7 @@ impl ChatWidget { duration_ms: Option, from_replay: bool, ) { - self.submit_pending_steers_after_interrupt = false; + self.input_queue.submit_pending_steers_after_interrupt = false; // Use `last_agent_message` from the turn-complete notification as the copy // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This @@ -2490,7 +2264,7 @@ impl ChatWidget { if let Some(message) = last_agent_message .as_ref() .filter(|message| !message.is_empty()) - && !self.saw_copy_source_this_turn + && !self.transcript.saw_copy_source_this_turn { self.record_agent_markdown(message); } @@ -2501,14 +2275,14 @@ impl ChatWidget { .filter(|message| !message.is_empty()) .cloned() .or_else(|| { - if self.saw_copy_source_this_turn { - self.last_agent_markdown.clone() + if self.transcript.saw_copy_source_this_turn { + self.transcript.last_agent_markdown.clone() } else { None } }) .unwrap_or_default(); - self.saw_copy_source_this_turn = false; + self.transcript.saw_copy_source_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); if let Some(mut controller) = self.plan_stream_controller.take() { @@ -2526,8 +2300,8 @@ impl ChatWidget { self.collect_runtime_metrics_delta(); let runtime_metrics = (!self.turn_runtime_metrics.is_empty()).then_some(self.turn_runtime_metrics); - let show_work_separator = self.had_work_activity - && (self.needs_final_message_separator || runtime_metrics.is_some()); + let show_work_separator = self.transcript.had_work_activity + && (self.transcript.needs_final_message_separator || runtime_metrics.is_some()); if show_work_separator || runtime_metrics.is_some() { let elapsed_seconds = if show_work_separator { duration_ms @@ -2547,18 +2321,15 @@ impl ChatWidget { )); } self.turn_runtime_metrics = RuntimeMetricsSummary::default(); - self.needs_final_message_separator = false; - self.had_work_activity = false; + self.transcript.needs_final_message_separator = false; + self.transcript.had_work_activity = false; self.request_status_line_branch_refresh(); self.request_status_line_git_summary_refresh(); } // Mark task stopped and request redraw now that all content is in history. - self.pending_status_indicator_restore = false; - self.user_turn_pending_start = false; - self.agent_turn_running = false; - self.goal_status_active_turn_started_at = None; - self.turn_sleep_inhibitor - .set_turn_running(/*turn_running*/ false); + self.status_state.pending_status_indicator_restore = false; + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); self.update_task_running_state(); self.running_commands.clear(); self.suppressed_exec_calls.clear(); @@ -2566,7 +2337,7 @@ impl ChatWidget { self.unified_exec_wait_streak = None; self.request_redraw(); - let had_pending_steers = !self.pending_steers.is_empty(); + let had_pending_steers = !self.input_queue.pending_steers.is_empty(); self.refresh_pending_input_preview(); if !from_replay && !self.has_queued_follow_up_messages() && !had_pending_steers { @@ -2575,7 +2346,7 @@ impl ChatWidget { // Keep this flag for replayed completion events so a subsequent live TurnComplete can // still show the prompt once after thread switch replay. if !from_replay { - self.saw_plan_item_this_turn = false; + self.transcript.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. let follow_up_started = self.maybe_send_next_queued_input(); @@ -2606,7 +2377,7 @@ impl ChatWidget { if self.active_mode_kind() != ModeKind::Plan { return; } - if !self.saw_plan_item_this_turn { + if !self.transcript.saw_plan_item_this_turn { return; } if !self.bottom_pane.no_modal_or_popup_active() { @@ -2630,7 +2401,7 @@ impl ChatWidget { self.bottom_pane .show_selection_view(plan_implementation::selection_view_params( default_mask, - self.latest_proposed_plan_markdown.as_deref(), + self.transcript.latest_proposed_plan_markdown.as_deref(), context_usage_label.as_deref(), )); self.notify(Notification::PlanModePrompt { @@ -2668,23 +2439,32 @@ impl ChatWidget { } fn has_queued_follow_up_messages(&self) -> bool { - !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + self.input_queue.has_queued_follow_up_messages() } fn pop_next_queued_user_message( &mut self, ) -> Option<(QueuedUserMessage, UserMessageHistoryRecord)> { - if self.rejected_steers_queue.is_empty() { - self.queued_user_messages.pop_front().map(|user_message| { - let history_record = self - .queued_user_message_history_records - .pop_front() - .unwrap_or(UserMessageHistoryRecord::UserMessageText); - (user_message, history_record) - }) + if self.input_queue.rejected_steers_queue.is_empty() { + self.input_queue + .queued_user_messages + .pop_front() + .map(|user_message| { + let history_record = self + .input_queue + .queued_user_message_history_records + .pop_front() + .unwrap_or(UserMessageHistoryRecord::UserMessageText); + (user_message, history_record) + }) } else { - let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); let mut history_records = self + .input_queue .rejected_steer_history_records .drain(..) .collect::>(); @@ -2703,8 +2483,9 @@ impl ChatWidget { } fn pop_latest_queued_user_message(&mut self) -> Option { - if let Some(user_message) = self.queued_user_messages.pop_back() { + if let Some(user_message) = self.input_queue.queued_user_messages.pop_back() { let history_record = self + .input_queue .queued_user_message_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); @@ -2713,8 +2494,9 @@ impl ChatWidget { &history_record, )) } else { - let user_message = self.rejected_steers_queue.pop_back()?; + let user_message = self.input_queue.rejected_steers_queue.pop_back()?; let history_record = self + .input_queue .rejected_steer_history_records .pop_back() .unwrap_or(UserMessageHistoryRecord::UserMessageText); @@ -2723,15 +2505,17 @@ impl ChatWidget { } pub(crate) fn enqueue_rejected_steer(&mut self) -> bool { - let Some(pending_steer) = self.pending_steers.pop_front() else { + let Some(pending_steer) = self.input_queue.pending_steers.pop_front() else { tracing::warn!( "received active-turn-not-steerable error without a matching pending steer" ); return false; }; - self.rejected_steers_queue + self.input_queue + .rejected_steers_queue .push_back(pending_steer.user_message); - self.rejected_steer_history_records + self.input_queue + .rejected_steer_history_records .push_back(pending_steer.history_record); self.refresh_pending_input_preview(); true @@ -2871,7 +2655,7 @@ impl ChatWidget { } fn restore_pre_review_token_info(&mut self) { - if let Some(saved) = self.pre_review_token_info.take() { + if let Some(saved) = self.review.pre_review_token_info.take() { match saved { Some(info) => self.apply_token_info(info), None => { @@ -2997,11 +2781,8 @@ impl ChatWidget { self.bump_active_cell_revision(); } // Reset running state and clear streaming buffers. - self.user_turn_pending_start = false; - self.agent_turn_running = false; - self.goal_status_active_turn_started_at = None; - self.turn_sleep_inhibitor - .set_turn_running(/*turn_running*/ false); + self.input_queue.user_turn_pending_start = false; + self.turn_lifecycle.finish(); self.update_task_running_state(); self.running_commands.clear(); self.suppressed_exec_calls.clear(); @@ -3010,14 +2791,14 @@ impl ChatWidget { self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; - self.pending_status_indicator_restore = false; + self.status_state.pending_status_indicator_restore = false; self.request_status_line_branch_refresh(); self.request_status_line_git_summary_refresh(); self.maybe_show_pending_rate_limit_prompt(); } fn on_server_overloaded_error(&mut self, message: String) { - self.submit_pending_steers_after_interrupt = false; + self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); let message = if message.trim().is_empty() { @@ -3032,7 +2813,7 @@ impl ChatWidget { } fn on_error(&mut self, message: String) { - self.submit_pending_steers_after_interrupt = false; + self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); @@ -3042,7 +2823,7 @@ impl ChatWidget { } fn on_cyber_policy_error(&mut self) { - self.submit_pending_steers_after_interrupt = false; + self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_cyber_policy_error_event()); self.request_redraw(); @@ -3158,8 +2939,9 @@ impl ChatWidget { fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); - let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt; - self.submit_pending_steers_after_interrupt = false; + let send_pending_steers_immediately = + self.input_queue.submit_pending_steers_after_interrupt; + self.input_queue.submit_pending_steers_after_interrupt = false; if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( @@ -3178,6 +2960,7 @@ impl ChatWidget { // tracked here must be restored locally instead of waiting for a later commit. if send_pending_steers_immediately { let pending_steers = self + .input_queue .pending_steers .drain(..) .map(|pending| (pending.user_message, pending.history_record)) @@ -3205,7 +2988,7 @@ impl ChatWidget { /// state stays aligned with the merged attachment list. Returns `None` when there is nothing to /// restore. fn drain_pending_messages_for_restore(&mut self) -> Option { - if self.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { + if self.input_queue.pending_steers.is_empty() && !self.has_queued_follow_up_messages() { return None; } @@ -3217,8 +3000,13 @@ impl ChatWidget { mention_bindings: self.bottom_pane.composer_mention_bindings(), }; - let rejected_messages = self.rejected_steers_queue.drain(..).collect::>(); + let rejected_messages = self + .input_queue + .rejected_steers_queue + .drain(..) + .collect::>(); let mut rejected_history_records = self + .input_queue .rejected_steer_history_records .drain(..) .collect::>(); @@ -3232,12 +3020,18 @@ impl ChatWidget { .map(|(message, history_record)| user_message_for_restore(message, history_record)) .collect(); to_merge.extend( - self.pending_steers + self.input_queue + .pending_steers .drain(..) .map(|steer| user_message_for_restore(steer.user_message, &steer.history_record)), ); - let queued_messages = self.queued_user_messages.drain(..).collect::>(); + let queued_messages = self + .input_queue + .queued_user_messages + .drain(..) + .collect::>(); let mut queued_history_records = self + .input_queue .queued_user_message_history_records .drain(..) .collect::>(); @@ -3293,29 +3087,35 @@ impl ChatWidget { Some(ThreadInputState { composer: composer.has_content().then_some(composer), pending_steers: self + .input_queue .pending_steers .iter() .map(|pending| pending.user_message.clone()) .collect(), pending_steer_history_records: self + .input_queue .pending_steers .iter() .map(|pending| pending.history_record.clone()) .collect(), pending_steer_compare_keys: self + .input_queue .pending_steers .iter() .map(|pending| pending.compare_key.clone()) .collect(), - rejected_steers_queue: self.rejected_steers_queue.clone(), - rejected_steer_history_records: self.rejected_steer_history_records.clone(), - queued_user_messages: self.queued_user_messages.clone(), - queued_user_message_history_records: self.queued_user_message_history_records.clone(), - user_turn_pending_start: self.user_turn_pending_start, + rejected_steers_queue: self.input_queue.rejected_steers_queue.clone(), + rejected_steer_history_records: self.input_queue.rejected_steer_history_records.clone(), + queued_user_messages: self.input_queue.queued_user_messages.clone(), + queued_user_message_history_records: self + .input_queue + .queued_user_message_history_records + .clone(), + user_turn_pending_start: self.input_queue.user_turn_pending_start, current_collaboration_mode: self.current_collaboration_mode.clone(), active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), - agent_turn_running: self.agent_turn_running, + agent_turn_running: self.turn_lifecycle.agent_turn_running, }) } @@ -3324,10 +3124,9 @@ impl ChatWidget { if let Some(input_state) = input_state { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; - self.agent_turn_running = input_state.agent_turn_running; - self.goal_status_active_turn_started_at = - self.agent_turn_running.then_some(Instant::now()); - self.user_turn_pending_start = input_state.user_turn_pending_start; + self.turn_lifecycle + .restore_running(input_state.agent_turn_running, Instant::now()); + self.input_queue.user_turn_pending_start = input_state.user_turn_pending_start; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); if let Some(composer) = input_state.composer { @@ -3361,7 +3160,7 @@ impl ChatWidget { UserMessageHistoryRecord::UserMessageText, ); let mut pending_steer_compare_keys = input_state.pending_steer_compare_keys; - self.pending_steers = input_state + self.input_queue.pending_steers = input_state .pending_steers .into_iter() .zip(pending_steer_history_records) @@ -3377,26 +3176,24 @@ impl ChatWidget { user_message, }) .collect(); - self.rejected_steers_queue = input_state.rejected_steers_queue; - self.rejected_steer_history_records = input_state.rejected_steer_history_records; - self.rejected_steer_history_records.resize( - self.rejected_steers_queue.len(), + self.input_queue.rejected_steers_queue = input_state.rejected_steers_queue; + self.input_queue.rejected_steer_history_records = + input_state.rejected_steer_history_records; + self.input_queue.rejected_steer_history_records.resize( + self.input_queue.rejected_steers_queue.len(), UserMessageHistoryRecord::UserMessageText, ); - self.queued_user_messages = input_state.queued_user_messages; - self.queued_user_message_history_records = + self.input_queue.queued_user_messages = input_state.queued_user_messages; + self.input_queue.queued_user_message_history_records = input_state.queued_user_message_history_records; - self.queued_user_message_history_records.resize( - self.queued_user_messages.len(), + self.input_queue.queued_user_message_history_records.resize( + self.input_queue.queued_user_messages.len(), UserMessageHistoryRecord::UserMessageText, ); } else { - self.agent_turn_running = false; - self.goal_status_active_turn_started_at = None; - self.user_turn_pending_start = false; - self.pending_steers.clear(); - self.rejected_steers_queue.clear(); - self.rejected_steer_history_records.clear(); + self.turn_lifecycle + .restore_running(/*running*/ false, Instant::now()); + self.input_queue.clear(); self.set_remote_image_urls(Vec::new()); self.bottom_pane.set_composer_text_with_mention_bindings( String::new(), @@ -3405,11 +3202,9 @@ impl ChatWidget { Vec::new(), ); self.bottom_pane.set_composer_pending_pastes(Vec::new()); - self.queued_user_messages.clear(); - self.queued_user_message_history_records.clear(); } - self.turn_sleep_inhibitor - .set_turn_running(self.agent_turn_running); + self.turn_lifecycle + .restore_running(self.turn_lifecycle.agent_turn_running, Instant::now()); self.update_task_running_state(); if restored_task_running && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); @@ -3420,11 +3215,11 @@ impl ChatWidget { } pub(crate) fn set_queue_autosend_suppressed(&mut self, suppressed: bool) { - self.suppress_queue_autosend = suppressed; + self.input_queue.suppress_queue_autosend = suppressed; } fn on_plan_update(&mut self, update: UpdatePlanArgs) { - self.saw_plan_update_this_turn = true; + self.transcript.saw_plan_update_this_turn = true; let total = update.plan.len(); let completed = update .plan @@ -3434,7 +3229,7 @@ impl ChatWidget { StepStatus::Pending | StepStatus::InProgress => false, }) .count(); - self.last_plan_progress = (total > 0).then_some((completed, total)); + self.transcript.last_plan_progress = (total > 0).then_some((completed, total)); self.refresh_status_surfaces(); self.add_to_history(history_cell::new_plan_update(update)); } @@ -3529,9 +3324,14 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); - self.pending_guardian_review_status + self.status_state + .pending_guardian_review_status .start_or_update(ev.id.clone(), detail); - if let Some(status) = self.pending_guardian_review_status.status_indicator_state() { + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { self.set_status( status.header, status.details, @@ -3545,19 +3345,27 @@ impl ChatWidget { // Terminal assessments remove the matching pending footer entry first, // then render the final approved/denied history cell below. - if self.pending_guardian_review_status.finish(&ev.id) { - if let Some(status) = self.pending_guardian_review_status.status_indicator_state() { + if self + .status_state + .pending_guardian_review_status + .finish(&ev.id) + { + if let Some(status) = self + .status_state + .pending_guardian_review_status + .status_indicator_state() + { self.set_status( status.header, status.details, StatusDetailsCapitalization::Preserve, status.details_max_lines, ); - } else if self.current_status.is_guardian_review() { + } else if self.status_state.current_status.is_guardian_review() { self.set_status_header(String::from("Working")); } - } else if self.pending_guardian_review_status.is_empty() - && self.current_status.is_guardian_review() + } 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")); } @@ -3626,7 +3434,7 @@ impl ChatWidget { if ev.status != GuardianAssessmentStatus::Denied { return; } - self.recent_auto_review_denials.push(ev.clone()); + self.review.recent_auto_review_denials.push(ev.clone()); let cell = if let Some(command) = guardian_command(&ev.action) { history_cell::new_approval_decision_cell( command, @@ -3737,6 +3545,7 @@ impl ChatWidget { } let Some(cell) = self + .transcript .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) @@ -3767,7 +3576,8 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); - self.terminal_title_status_kind = TerminalTitleStatusKind::WaitingForBackgroundTerminal; + self.status_state.terminal_title_status_kind = + TerminalTitleStatusKind::WaitingForBackgroundTerminal; self.set_status( "Waiting for background terminal".to_string(), command_display.clone(), @@ -3967,7 +3777,7 @@ impl ChatWidget { fn on_web_search_begin(&mut self, call_id: String) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_web_search_call( + self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( call_id, String::new(), self.config.animations, @@ -3985,6 +3795,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); let mut handled = false; if let Some(cell) = self + .transcript .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) @@ -4000,7 +3811,7 @@ impl ChatWidget { if !handled { self.add_to_history(history_cell::new_web_search_call(call_id, query, action)); } - self.had_work_activity = true; + self.transcript.had_work_activity = true; } fn on_collab_event(&mut self, cell: PlainHistoryCell) { @@ -4136,7 +3947,7 @@ impl ChatWidget { self.active_hook_cell = None; } self.bump_active_cell_revision(); - self.needs_final_message_separator = true; + self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(completed_cell))); } @@ -4154,7 +3965,7 @@ impl ChatWidget { && let Some(cell) = self.active_hook_cell.take() { self.bump_active_cell_revision(); - self.needs_final_message_separator = true; + self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(cell))); } @@ -4194,11 +4005,9 @@ impl ChatWidget { } fn on_stream_error(&mut self, message: String, additional_details: Option) { - if self.retry_status_header.is_none() { - self.retry_status_header = Some(self.current_status.header.clone()); - } + self.status_state.remember_retry_status_header(); self.bottom_pane.ensure_status_indicator(); - self.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; + self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking; self.set_status( message, additional_details, @@ -4241,9 +4050,9 @@ impl ChatWidget { if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None) && !message.is_empty() { self.record_agent_markdown(&message); } - self.pending_status_indicator_restore = match item.phase { + self.status_state.pending_status_indicator_restore = match item.phase { // Models that don't support preambles only output AgentMessageItems on turn completion. - Some(MessagePhase::FinalAnswer) | None => !self.pending_steers.is_empty(), + Some(MessagePhase::FinalAnswer) | None => !self.input_queue.pending_steers.is_empty(), Some(MessagePhase::Commentary) => true, }; self.maybe_restore_status_indicator_after_stream_idle(); @@ -4290,7 +4099,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::StopCommitAnimation); } - if self.agent_turn_running { + if self.turn_lifecycle.agent_turn_running { self.refresh_runtime_metrics(); } } @@ -4335,14 +4144,14 @@ impl ChatWidget { if self.stream_controller.is_none() { // If the previous turn inserted non-stream history (exec output, patch status, MCP // calls), render a separator before starting the next streamed assistant message. - if self.needs_final_message_separator && self.had_work_activity { + if self.transcript.needs_final_message_separator && self.transcript.had_work_activity { self.add_to_history(history_cell::FinalMessageSeparator::new( /*elapsed_seconds*/ None, /*runtime_metrics*/ None, )); - self.needs_final_message_separator = false; - } else if self.needs_final_message_separator { + self.transcript.needs_final_message_separator = false; + } else if self.transcript.needs_final_message_separator { // Reset the flag even if we don't show separator (no work was done) - self.needs_final_message_separator = false; + self.transcript.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( self.current_stream_width(/*reserved_cols*/ 2), @@ -4413,7 +4222,7 @@ impl ChatWidget { let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); let is_user_shell = source == ExecCommandSource::UserShell; - let end_target = match self.active_cell.as_ref() { + let end_target = match self.transcript.active_cell.as_ref() { Some(cell) => match cell.as_any().downcast_ref::() { Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => { ExecEndTarget::ActiveTracked @@ -4445,6 +4254,7 @@ impl ChatWidget { match end_target { ExecEndTarget::ActiveTracked => { if let Some(cell) = self + .transcript .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) @@ -4470,7 +4280,7 @@ impl ChatWidget { ); let completed = orphan.complete_call(&id, output, duration); debug_assert!(completed, "new orphan exec cell should contain {id}"); - self.needs_final_message_separator = true; + self.transcript.needs_final_message_separator = true; self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(orphan))); self.request_redraw(); @@ -4490,14 +4300,14 @@ impl ChatWidget { if cell.should_flush() { self.add_to_history(cell); } else { - self.active_cell = Some(Box::new(cell)); + self.transcript.active_cell = Some(Box::new(cell)); self.bump_active_cell_revision(); self.request_redraw(); } } } // Mark that actual work was done (command executed) - self.had_work_activity = true; + self.transcript.had_work_activity = true; if is_user_shell { self.maybe_send_next_queued_input(); } @@ -4513,7 +4323,7 @@ impl ChatWidget { self.add_to_history(history_cell::new_patch_apply_failure(String::new())); } // Mark that actual work was done (patch applied) - self.had_work_activity = true; + self.transcript.had_work_activity = true; } pub(crate) fn handle_exec_approval_now(&mut self, ev: ExecApprovalRequestEvent) { @@ -4696,6 +4506,7 @@ impl ChatWidget { return; } if let Some(cell) = self + .transcript .active_cell .as_mut() .and_then(|c| c.as_any_mut().downcast_mut::()) @@ -4712,7 +4523,7 @@ impl ChatWidget { } else { self.flush_active_cell(); - self.active_cell = Some(Box::new(new_active_exec_command( + self.transcript.active_cell = Some(Box::new(new_active_exec_command( id, command, parsed_cmd, @@ -4739,7 +4550,7 @@ impl ChatWidget { }; self.flush_answer_stream_with_separator(); self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( + self.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( id, McpInvocation { server, @@ -4789,6 +4600,7 @@ impl ChatWidget { }; let extra_cell = match self + .transcript .active_cell .as_mut() .and_then(|cell| cell.as_any_mut().downcast_mut::()) @@ -4799,7 +4611,7 @@ impl ChatWidget { let mut cell = history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); let extra_cell = cell.complete(duration, result); - self.active_cell = Some(Box::new(cell)); + self.transcript.active_cell = Some(Box::new(cell)); extra_cell } }; @@ -4809,7 +4621,7 @@ impl ChatWidget { self.add_boxed_history(extra); } // Mark that actual work was done (MCP tool call) - self.had_work_activity = true; + self.transcript.had_work_activity = true; } pub(crate) fn handle_queued_item_started_now(&mut self, item: ThreadItem) { @@ -4924,8 +4736,7 @@ impl ChatWidget { animations_enabled: config.animations, skills: None, }), - active_cell, - active_cell_revision: 0, + transcript: TranscriptState::new(active_cell), raw_output_mode: config.tui_raw_output_mode, config, environment_manager, @@ -4962,26 +4773,16 @@ impl ChatWidget { suppressed_exec_calls: HashSet::new(), last_unified_wait: None, unified_exec_wait_streak: None, - turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), + turn_lifecycle: TurnLifecycleState::new(prevent_idle_sleep), task_complete_pending: false, unified_exec_processes: Vec::new(), - agent_turn_running: false, mcp_startup_status: None, - last_agent_markdown: None, - agent_turn_markdowns: Vec::new(), - visible_user_turn_count: 0, - copy_history_evicted_by_rollback: false, - latest_proposed_plan_markdown: None, - saw_copy_source_this_turn: false, mcp_startup_expected_servers: None, mcp_startup_ignore_updates_until_next_start: false, mcp_startup_allow_terminal_only_next_round: false, mcp_startup_pending_next_round: HashMap::new(), mcp_startup_pending_next_round_saw_starting: false, - connectors_cache: ConnectorsCacheState::default(), - connectors_partial_snapshot: None, - connectors_prefetch_in_flight: false, - connectors_force_refetch_pending: false, + connectors: ConnectorsState::default(), ide_context: IdeContextState::default(), plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), @@ -4992,18 +4793,11 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status: StatusIndicatorState::working(), - pending_guardian_review_status: PendingGuardianReviewStatus::default(), - recent_auto_review_denials: RecentAutoReviewDenials::default(), + status_state: StatusState::default(), + review: ReviewState::default(), active_hook_cell: None, - terminal_title_status_kind: TerminalTitleStatusKind::Working, - retry_status_header: None, - pending_status_indicator_restore: false, - suppress_queue_autosend: false, thread_id: None, dismissed_plan_mode_nudge_scopes: HashSet::new(), - last_turn_id: None, - budget_limited_turn_ids: HashSet::new(), thread_name: None, thread_rename_block_message: None, active_side_conversation: false, @@ -5011,13 +4805,7 @@ impl ChatWidget { side_placeholder_text: side_placeholder, forked_from: None, interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, - queued_user_messages: VecDeque::new(), - queued_user_message_history_records: VecDeque::new(), - user_turn_pending_start: false, - rejected_steers_queue: VecDeque::new(), - rejected_steer_history_records: VecDeque::new(), - pending_steers: VecDeque::new(), - submit_pending_steers_after_interrupt: false, + input_queue: InputQueueState::default(), chat_keymap, queued_message_edit_hint_binding, show_welcome_banner: is_first_run, @@ -5027,15 +4815,6 @@ impl ChatWidget { pending_notification: None, quit_shortcut_expires_at: None, quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - saw_plan_update_this_turn: false, - saw_plan_item_this_turn: false, - last_plan_progress: None, - plan_delta_buffer: String::new(), - plan_item_active: false, turn_runtime_metrics: RuntimeMetricsSummary::default(), last_rendered_width: std::cell::Cell::new(None), feedback, @@ -5061,7 +4840,6 @@ impl ChatWidget { status_line_git_summary_lookup_complete: false, current_goal_status_indicator: None, current_goal_status: None, - goal_status_active_turn_started_at: None, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_display: None, @@ -5223,14 +5001,14 @@ impl ChatWidget { if matches!(key_event.code, KeyCode::Esc) && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && !self.pending_steers.is_empty() + && !self.input_queue.pending_steers.is_empty() && self.bottom_pane.is_task_running() && self.bottom_pane.no_modal_or_popup_active() && !self.should_handle_vim_insert_escape(key_event) { - self.submit_pending_steers_after_interrupt = true; + self.input_queue.submit_pending_steers_after_interrupt = true; if !self.submit_op(AppCommand::interrupt()) { - self.submit_pending_steers_after_interrupt = false; + self.input_queue.submit_pending_steers_after_interrupt = false; } return; } @@ -5418,17 +5196,8 @@ impl ChatWidget { &mut self, user_turn_count: usize, ) { - self.visible_user_turn_count = user_turn_count; - let had_copy_history = !self.agent_turn_markdowns.is_empty(); - self.agent_turn_markdowns - .retain(|entry| entry.user_turn_count <= user_turn_count); - self.last_agent_markdown = self - .agent_turn_markdowns - .last() - .map(|entry| entry.markdown.clone()); - self.copy_history_evicted_by_rollback = - had_copy_history && self.last_agent_markdown.is_none(); - self.saw_copy_source_this_turn = false; + self.transcript + .truncate_copy_history_to_user_turn_count(user_turn_count); } /// Inner implementation with an injectable clipboard backend for testing. @@ -5436,7 +5205,7 @@ impl ChatWidget { &mut self, copy_fn: impl FnOnce(&str) -> Result, String>, ) { - match self.last_agent_markdown.clone() { + match self.transcript.last_agent_markdown.clone() { Some(markdown) if !markdown.is_empty() => match copy_fn(&markdown) { Ok(lease) => { self.clipboard_lease = lease; @@ -5449,7 +5218,7 @@ impl ChatWidget { "Copy failed: {error}" ))), }, - _ if self.copy_history_evicted_by_rollback => { + _ if self.transcript.copy_history_evicted_by_rollback => { self.add_to_history(history_cell::new_error_event(format!( "Cannot copy that response after rewinding. Only the most recent {MAX_AGENT_COPY_HISTORY} responses are available to /copy." ))); @@ -5463,7 +5232,7 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> { - self.last_agent_markdown.as_deref() + self.transcript.last_agent_markdown.as_deref() } fn show_rename_prompt(&mut self) { @@ -5531,8 +5300,8 @@ impl ChatWidget { } fn flush_active_cell(&mut self) { - if let Some(active) = self.active_cell.take() { - self.needs_final_message_separator = true; + if let Some(active) = self.transcript.active_cell.take() { + self.transcript.needs_final_message_separator = true; self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); } } @@ -5546,6 +5315,7 @@ impl ChatWidget { // so we can merge headers instead of committing a duplicate box to history. let keep_placeholder_header_active = !self.is_session_configured() && self + .transcript .active_cell .as_ref() .is_some_and(|c| c.as_any().is::()); @@ -5553,7 +5323,7 @@ impl ChatWidget { if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() { // Only break exec grouping if the cell renders visible lines. self.flush_active_cell(); - self.needs_final_message_separator = true; + self.transcript.needs_final_message_separator = true; } self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } @@ -5568,9 +5338,11 @@ impl ChatWidget { action: QueuedInputAction, ) { if !self.is_session_configured() || self.is_user_turn_pending_or_running() { - self.queued_user_messages + self.input_queue + .queued_user_messages .push_back(QueuedUserMessage::new(user_message, action)); - self.queued_user_message_history_records + self.input_queue + .queued_user_message_history_records .push_back(UserMessageHistoryRecord::UserMessageText); self.refresh_pending_input_preview(); } else { @@ -5660,9 +5432,11 @@ impl ChatWidget { ) -> (bool, Option) { if !self.is_session_configured() { tracing::warn!("cannot submit user message before session is configured; queueing"); - self.queued_user_messages + self.input_queue + .queued_user_messages .push_front(QueuedUserMessage::from(user_message)); - self.queued_user_message_history_records + self.input_queue + .queued_user_message_history_records .push_front(history_record); self.refresh_pending_input_preview(); return (true, None); @@ -5700,7 +5474,7 @@ impl ChatWidget { mention_bindings, } = user_message; - let render_in_history = !self.agent_turn_running; + let render_in_history = !self.turn_lifecycle.agent_turn_running; let mut items: Vec = Vec::new(); // Special-case: "!cmd" executes a local shell command instead of sending to the model. @@ -5908,7 +5682,7 @@ impl ChatWidget { return (false, None); } if render_in_history { - self.user_turn_pending_start = true; + self.input_queue.user_turn_pending_start = true; } // Persist the submitted text to cross-session message history. Mentions are encoded into @@ -5936,8 +5710,8 @@ impl ChatWidget { } if let Some(pending_steer) = pending_steer { - self.pending_steers.push_back(pending_steer); - self.saw_plan_item_this_turn = false; + self.input_queue.pending_steers.push_back(pending_steer); + self.transcript.saw_plan_item_this_turn = false; self.refresh_pending_input_preview(); } @@ -5958,7 +5732,7 @@ impl ChatWidget { self.on_user_message_display(display); } - self.needs_final_message_separator = false; + self.transcript.needs_final_message_separator = false; (true, Some(op)) } @@ -6218,355 +5992,10 @@ impl ChatWidget { } } - pub(crate) fn handle_server_notification( - &mut self, - notification: ServerNotification, - replay_kind: Option, - ) { - if self.active_side_conversation - && replay_kind.is_none() - && matches!(notification, ServerNotification::McpServerStatusUpdated(_)) - { - return; - } - let from_replay = replay_kind.is_some(); - let is_resume_initial_replay = - matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); - let is_retry_error = matches!( - ¬ification, - ServerNotification::Error(ErrorNotification { - will_retry: true, - .. - }) - ); - if !is_resume_initial_replay && !is_retry_error { - self.restore_retry_status_header_if_present(); - } - match notification { - ServerNotification::ThreadTokenUsageUpdated(notification) => { - self.set_token_info(Some(token_usage_info_from_app_server( - notification.token_usage, - ))); - } - ServerNotification::ThreadNameUpdated(notification) => { - match ThreadId::from_string(¬ification.thread_id) { - Ok(thread_id) => { - self.on_thread_name_updated(thread_id, notification.thread_name) - } - Err(err) => { - tracing::warn!( - thread_id = notification.thread_id, - error = %err, - "ignoring app-server ThreadNameUpdated with invalid thread_id" - ); - } - } - } - ServerNotification::ThreadGoalUpdated(notification) => { - self.on_thread_goal_updated(notification.goal, notification.turn_id); - } - ServerNotification::ThreadGoalCleared(notification) => { - self.on_thread_goal_cleared(notification.thread_id.as_str()); - } - ServerNotification::TurnStarted(notification) => { - self.last_turn_id = Some(notification.turn.id); - self.last_non_retry_error = None; - if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { - self.on_task_started(); - } - } - ServerNotification::TurnCompleted(notification) => { - self.handle_turn_completed_notification(notification, replay_kind); - } - ServerNotification::ItemStarted(notification) => { - self.handle_item_started_notification(notification, replay_kind.is_some()); - } - ServerNotification::ItemCompleted(notification) => { - self.handle_item_completed_notification(notification, replay_kind); - } - ServerNotification::AgentMessageDelta(notification) => { - self.on_agent_message_delta(notification.delta); - } - ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta), - ServerNotification::ReasoningSummaryTextDelta(notification) => { - self.on_agent_reasoning_delta(notification.delta); - } - ServerNotification::ReasoningTextDelta(notification) => { - if self.config.show_raw_agent_reasoning { - self.on_agent_reasoning_delta(notification.delta); - } - } - ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), - ServerNotification::TerminalInteraction(notification) => { - self.on_terminal_interaction(notification.process_id, notification.stdin) - } - ServerNotification::CommandExecutionOutputDelta(notification) => { - self.on_exec_command_output_delta(¬ification.item_id, ¬ification.delta); - } - ServerNotification::FileChangeOutputDelta(notification) => { - self.on_patch_apply_output_delta(notification.item_id, notification.delta); - } - ServerNotification::TurnDiffUpdated(notification) => { - self.on_turn_diff(notification.diff) - } - ServerNotification::TurnPlanUpdated(notification) => { - self.on_plan_update(UpdatePlanArgs { - explanation: notification.explanation, - plan: notification - .plan - .into_iter() - .map(|step| UpdatePlanItemArg { - step: step.step, - status: match step.status { - TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending, - TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress, - TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed, - }, - }) - .collect(), - }) - } - ServerNotification::HookStarted(notification) => { - self.on_hook_started(notification.run); - } - ServerNotification::HookCompleted(notification) => { - self.on_hook_completed(notification.run); - } - ServerNotification::Error(notification) => { - if notification.will_retry { - if !from_replay { - self.on_stream_error( - notification.error.message, - notification.error.additional_details, - ); - } - } else { - self.last_non_retry_error = Some(( - notification.turn_id.clone(), - notification.error.message.clone(), - )); - self.handle_non_retry_error( - notification.error.message, - notification.error.codex_error_info, - ); - } - } - ServerNotification::SkillsChanged(_) => { - self.refresh_skills_for_current_cwd(/*force_reload*/ true); - } - ServerNotification::ModelRerouted(_) => {} - ServerNotification::ModelVerification(notification) => { - self.on_app_server_model_verification(¬ification.verifications) - } - ServerNotification::Warning(notification) => self.on_warning(notification.message), - ServerNotification::GuardianWarning(notification) => { - self.on_warning(notification.message) - } - ServerNotification::DeprecationNotice(notification) => { - self.on_deprecation_notice(notification.summary, notification.details) - } - ServerNotification::ConfigWarning(notification) => self.on_warning( - notification - .details - .map(|details| format!("{}: {details}", notification.summary)) - .unwrap_or(notification.summary), - ), - ServerNotification::McpServerStatusUpdated(notification) => { - self.on_mcp_server_status_updated(notification) - } - ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { - self.on_guardian_review_notification( - notification.review_id, - notification.turn_id, - notification.started_at_ms, - notification.review, - /*completion*/ None, - notification.action, - ); - } - ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { - self.on_guardian_review_notification( - notification.review_id, - notification.turn_id, - notification.started_at_ms, - notification.review, - Some((notification.completed_at_ms, notification.decision_source)), - notification.action, - ); - } - ServerNotification::ThreadClosed(_) => { - if !from_replay { - self.on_shutdown_complete(); - } - } - ServerNotification::ThreadRealtimeStarted(notification) => { - if !from_replay { - self.on_realtime_conversation_started(notification); - } - } - ServerNotification::ThreadRealtimeItemAdded(notification) => { - if !from_replay { - self.on_realtime_item_added(notification); - } - } - ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { - if !from_replay { - self.on_realtime_output_audio_delta(notification); - } - } - ServerNotification::ThreadRealtimeError(notification) => { - if !from_replay { - self.on_realtime_error(notification); - } - } - ServerNotification::ThreadRealtimeClosed(notification) => { - if !from_replay { - self.on_realtime_conversation_closed(notification); - } - } - ServerNotification::ThreadRealtimeSdp(notification) => { - if !from_replay { - self.on_realtime_conversation_sdp(notification.sdp); - } - } - ServerNotification::ServerRequestResolved(_) - | ServerNotification::AccountUpdated(_) - | ServerNotification::AccountRateLimitsUpdated(_) - | ServerNotification::ThreadStarted(_) - | ServerNotification::ThreadStatusChanged(_) - | ServerNotification::ThreadArchived(_) - | ServerNotification::ThreadUnarchived(_) - | ServerNotification::RawResponseItemCompleted(_) - | ServerNotification::CommandExecOutputDelta(_) - | ServerNotification::ProcessOutputDelta(_) - | ServerNotification::ProcessExited(_) - | ServerNotification::FileChangePatchUpdated(_) - | ServerNotification::McpToolCallProgress(_) - | ServerNotification::McpServerOauthLoginCompleted(_) - | ServerNotification::AppListUpdated(_) - | ServerNotification::RemoteControlStatusChanged(_) - | ServerNotification::ExternalAgentConfigImportCompleted(_) - | ServerNotification::FsChanged(_) - | ServerNotification::FuzzyFileSearchSessionUpdated(_) - | ServerNotification::FuzzyFileSearchSessionCompleted(_) - | ServerNotification::ThreadRealtimeTranscriptDelta(_) - | ServerNotification::ThreadRealtimeTranscriptDone(_) - | ServerNotification::WindowsWorldWritableWarning(_) - | ServerNotification::WindowsSandboxSetupCompleted(_) - | ServerNotification::AccountLoginCompleted(_) => {} - ServerNotification::ContextCompacted(_) => {} - } - } - pub(crate) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { self.on_list_skills(response); } - fn handle_turn_completed_notification( - &mut self, - notification: TurnCompletedNotification, - replay_kind: Option, - ) { - match notification.turn.status { - TurnStatus::Completed => { - self.last_non_retry_error = None; - self.on_task_complete( - /*last_agent_message*/ None, - notification.turn.duration_ms, - replay_kind.is_some(), - ) - } - TurnStatus::Interrupted => { - self.last_non_retry_error = None; - let reason = if self - .budget_limited_turn_ids - .remove(notification.turn.id.as_str()) - { - TurnAbortReason::BudgetLimited - } else { - TurnAbortReason::Interrupted - }; - self.on_interrupted_turn(reason); - } - TurnStatus::Failed => { - if let Some(error) = notification.turn.error { - if self.last_non_retry_error.as_ref() - == Some(&(notification.turn.id.clone(), error.message.clone())) - { - self.last_non_retry_error = None; - } else { - self.handle_non_retry_error(error.message, error.codex_error_info); - } - } else { - self.last_non_retry_error = None; - self.finalize_turn(); - self.request_redraw(); - self.maybe_send_next_queued_input(); - } - } - TurnStatus::InProgress => {} - } - } - - fn handle_item_started_notification( - &mut self, - notification: ItemStartedNotification, - from_replay: bool, - ) { - match notification.item { - item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), - ThreadItem::FileChange { id: _, changes, .. } => { - self.on_patch_apply_begin(file_update_changes_to_display(changes)); - } - item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), - ThreadItem::WebSearch { id, .. } => { - self.on_web_search_begin(id); - } - ThreadItem::ImageGeneration { .. } => { - self.on_image_generation_begin(); - } - ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { - id, - tool, - status, - sender_thread_id, - receiver_thread_ids, - prompt, - model, - reasoning_effort, - agents_states, - }), - ThreadItem::EnteredReviewMode { review, .. } => { - if !from_replay { - self.enter_review_mode_with_hint(review, /*from_replay*/ false); - } - } - _ => {} - } - } - - fn handle_item_completed_notification( - &mut self, - notification: ItemCompletedNotification, - replay_kind: Option, - ) { - self.handle_thread_item( - notification.item, - notification.turn_id, - replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay), - ); - } - fn on_patch_apply_output_delta(&mut self, _item_id: String, _delta: String) {} fn on_guardian_review_notification( @@ -6649,13 +6078,13 @@ impl ChatWidget { } fn enter_review_mode_with_hint(&mut self, hint: String, from_replay: bool) { - if self.pre_review_token_info.is_none() { - self.pre_review_token_info = Some(self.token_info.clone()); + if self.review.pre_review_token_info.is_none() { + self.review.pre_review_token_info = Some(self.token_info.clone()); } if !from_replay && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } - self.is_review_mode = true; + self.review.is_review_mode = true; let banner = format!(">> Code review started: {hint} <<"); self.add_to_history(history_cell::new_review_status_line(banner)); self.request_redraw(); @@ -6665,7 +6094,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); self.flush_interrupt_queue(); self.flush_active_cell(); - self.is_review_mode = false; + self.review.is_review_mode = false; self.restore_pre_review_token_info(); self.add_to_history(history_cell::new_review_status_line( "<< Code review finished >>".to_string(), @@ -6676,7 +6105,7 @@ impl ChatWidget { fn on_committed_user_message(&mut self, items: &[UserInput], from_replay: bool) { let display = Self::user_message_display_from_inputs(items); if from_replay { - if !self.is_review_mode { + if !self.review.is_review_mode { self.on_user_message_display(display); } return; @@ -6684,11 +6113,12 @@ impl ChatWidget { let compare_key = Self::pending_steer_compare_key_from_items(items); if self + .input_queue .pending_steers .front() .is_some_and(|pending| pending.compare_key == compare_key) { - if let Some(pending) = self.pending_steers.pop_front() { + if let Some(pending) = self.input_queue.pending_steers.pop_front() { self.refresh_pending_input_preview(); let pending_display = user_message_display_for_history(pending.user_message, &pending.history_record); @@ -6699,7 +6129,7 @@ impl ChatWidget { ); self.on_user_message_display(display); } - } else if !self.is_review_mode + } else if !self.review.is_review_mode && self.last_rendered_user_message_display.as_ref() != Some(&display) { self.on_user_message_display(display); @@ -6723,7 +6153,7 @@ impl ChatWidget { } // User messages reset separator state so the next agent response doesn't add a stray break. - self.needs_final_message_separator = false; + self.transcript.needs_final_message_separator = false; } /// Exit the UI immediately without waiting for shutdown. @@ -6748,9 +6178,7 @@ impl ChatWidget { } fn bump_active_cell_revision(&mut self) { - // Wrapping avoids overflow; wraparound would require 2^64 bumps and at - // worst causes a one-time cache-key collision. - self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + self.transcript.bump_active_cell_revision(); } fn notify(&mut self, notification: Notification) { @@ -6774,7 +6202,7 @@ impl ChatWidget { /// Mark the active cell as failed (✗) and flush it into history. fn finalize_active_cell_as_failed(&mut self) { - if let Some(mut cell) = self.active_cell.take() { + if let Some(mut cell) = self.transcript.active_cell.take() { // Insert finalized cell into history and keep grouping consistent. if let Some(exec) = cell.as_any_mut().downcast_mut::() { exec.mark_failed(); @@ -6787,7 +6215,7 @@ impl ChatWidget { // If idle and there are queued inputs, submit exactly one to start the next turn. pub(crate) fn maybe_send_next_queued_input(&mut self) -> bool { - if self.suppress_queue_autosend { + if self.input_queue.suppress_queue_autosend { return false; } if self.is_user_turn_pending_or_running() { @@ -6828,11 +6256,11 @@ impl ChatWidget { } pub(super) fn is_user_turn_pending_or_running(&self) -> bool { - self.user_turn_pending_start || self.bottom_pane.is_task_running() + self.input_queue.user_turn_pending_start || self.bottom_pane.is_task_running() } fn only_user_shell_commands_running(&self) -> bool { - self.agent_turn_running + self.turn_lifecycle.agent_turn_running && !self.running_commands.is_empty() && self .running_commands @@ -6842,36 +6270,11 @@ impl ChatWidget { /// Rebuild and update the bottom-pane pending-input preview. fn refresh_pending_input_preview(&mut self) { - let queued_messages: Vec = self - .queued_user_messages - .iter() - .enumerate() - .map(|(idx, message)| { - user_message_preview_text( - message, - self.queued_user_message_history_records.get(idx), - ) - }) - .collect(); - let pending_steers: Vec = self - .pending_steers - .iter() - .map(|steer| { - user_message_preview_text(&steer.user_message, Some(&steer.history_record)) - }) - .collect(); - let rejected_steers: Vec = self - .rejected_steers_queue - .iter() - .enumerate() - .map(|(idx, message)| { - user_message_preview_text(message, self.rejected_steer_history_records.get(idx)) - }) - .collect(); + let preview = self.input_queue.preview(); self.bottom_pane.set_pending_input_preview( - queued_messages, - pending_steers, - rejected_steers, + preview.queued_messages, + preview.pending_steers, + preview.rejected_steers, ); } @@ -7141,16 +6544,16 @@ impl ChatWidget { if !self.connectors_enabled() { return; } - if self.connectors_prefetch_in_flight { + if self.connectors.prefetch_in_flight { if force_refetch { - self.connectors_force_refetch_pending = true; + self.connectors.force_refetch_pending = true; } return; } - self.connectors_prefetch_in_flight = true; - if !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) { - self.connectors_cache = ConnectorsCacheState::Loading; + self.connectors.prefetch_in_flight = true; + if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { + self.connectors.cache = ConnectorsCacheState::Loading; } let config = self.config.clone(); @@ -7158,7 +6561,7 @@ impl ChatWidget { let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let accessible_result = - match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( &config, force_refetch, &environment_manager, @@ -7187,8 +6590,9 @@ impl ChatWidget { let result: Result = async { let all_connectors = - connectors::list_all_connectors_with_options(&config, force_refetch).await?; - let connectors = connectors::merge_connectors_with_accessible( + chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) + .await?; + let connectors = chatgpt_connectors::merge_connectors_with_accessible( all_connectors, accessible_connectors, /*all_connectors_loaded*/ true, @@ -8449,7 +7853,7 @@ impl ChatWidget { } pub(crate) fn open_auto_review_denials_popup(&mut self) { - if self.recent_auto_review_denials.is_empty() { + if self.review.recent_auto_review_denials.is_empty() { self.add_info_message( "No recent auto-review denials in this thread.".to_string(), Some("Denials are recorded after auto-review rejects an action.".to_string()), @@ -8468,28 +7872,33 @@ impl ChatWidget { search_value: Some(String::new()), ..Default::default() }]; - items.extend(self.recent_auto_review_denials.entries().map(|event| { - let id = event.id.clone(); - let summary = auto_review_denials::action_summary(&event.action); - let rationale = event - .rationale - .as_deref() - .unwrap_or("Auto-review did not include a rationale."); - SelectionItem { - name: summary.clone(), - description: Some(rationale.to_string()), - selected_description: Some(rationale.to_string()), - search_value: Some(format!("{summary} {rationale}")), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ApproveRecentAutoReviewDenial { - thread_id, - id: id.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - } - })); + items.extend( + self.review + .recent_auto_review_denials + .entries() + .map(|event| { + let id = event.id.clone(); + let summary = auto_review_denials::action_summary(&event.action); + let rationale = event + .rationale + .as_deref() + .unwrap_or("Auto-review did not include a rationale."); + SelectionItem { + name: summary.clone(), + description: Some(rationale.to_string()), + selected_description: Some(rationale.to_string()), + search_value: Some(format!("{summary} {rationale}")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ApproveRecentAutoReviewDenial { + thread_id, + id: id.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }), + ); self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Auto-review Denials".to_string()), @@ -8504,7 +7913,7 @@ impl ChatWidget { } pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { - let Some(event) = self.recent_auto_review_denials.take(&id) else { + let Some(event) = self.review.recent_auto_review_denials.take(&id) else { self.add_error_message("That auto-review denial is no longer available.".to_string()); return; }; @@ -9212,15 +8621,13 @@ impl ChatWidget { if !enabled { self.current_goal_status_indicator = None; self.current_goal_status = None; - self.goal_status_active_turn_started_at = None; - self.budget_limited_turn_ids.clear(); + self.turn_lifecycle.goal_status_active_turn_started_at = None; + self.turn_lifecycle.budget_limited_turn_ids.clear(); self.update_collaboration_mode_indicator(); } } if feature == Feature::PreventIdleSleep { - self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); - self.turn_sleep_inhibitor - .set_turn_running(self.agent_turn_running); + self.turn_lifecycle.set_prevent_idle_sleep(enabled); } #[cfg(target_os = "windows")] if matches!( @@ -9647,9 +9054,9 @@ impl ChatWidget { if !self.config.features.enabled(Feature::Goals) { return None; } - self.current_goal_status - .as_ref() - .and_then(|state| state.indicator(now, self.goal_status_active_turn_started_at)) + self.current_goal_status.as_ref().and_then(|state| { + state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at) + }) } fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { @@ -9667,7 +9074,7 @@ impl ChatWidget { if goal.status == AppThreadGoalStatus::BudgetLimited && let Some(turn_id) = turn_id { - self.budget_limited_turn_ids.insert(turn_id); + self.turn_lifecycle.mark_budget_limited(turn_id); } self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); self.update_collaboration_mode_indicator(); @@ -9763,11 +9170,11 @@ impl ChatWidget { return None; } - if let Some(snapshot) = &self.connectors_partial_snapshot { + if let Some(snapshot) = &self.connectors.partial_snapshot { return Some(snapshot.connectors.as_slice()); } - match &self.connectors_cache { + match &self.connectors.cache { ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), _ => None, } @@ -9800,18 +9207,18 @@ impl ChatWidget { /// Merge the real session info cell with any placeholder header to avoid double boxes. fn apply_session_info_cell(&mut self, cell: history_cell::SessionInfoCell) { let mut session_info_cell = Some(Box::new(cell) as Box); - let merged_header = if let Some(active) = self.active_cell.take() { + let merged_header = if let Some(active) = self.transcript.active_cell.take() { if active .as_any() .is::() { // Reuse the existing placeholder header to avoid rendering two boxes. if let Some(cell) = session_info_cell.take() { - self.active_cell = Some(cell); + self.transcript.active_cell = Some(cell); } true } else { - self.active_cell = Some(active); + self.transcript.active_cell = Some(active); false } } else { @@ -9874,7 +9281,7 @@ impl ChatWidget { pub(crate) fn add_mcp_output(&mut self, detail: McpServerStatusDetail) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); - self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( + self.transcript.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( self.config.animations, ))); self.bump_active_cell_revision(); @@ -9888,7 +9295,7 @@ impl ChatWidget { /// Uses `Any`-based type checking so that a late-arriving inventory result /// does not accidentally clear an unrelated cell that was set in the meantime. pub(crate) fn clear_mcp_inventory_loading(&mut self) { - let Some(active) = self.active_cell.as_ref() else { + let Some(active) = self.transcript.active_cell.as_ref() else { return; }; if !active @@ -9897,7 +9304,7 @@ impl ChatWidget { { return; } - self.active_cell = None; + self.transcript.active_cell = None; self.bump_active_cell_revision(); self.request_redraw(); } @@ -9911,8 +9318,8 @@ impl ChatWidget { return; } - let connectors_cache = self.connectors_cache.clone(); - let should_force_refetch = !self.connectors_prefetch_in_flight + let connectors_cache = self.connectors.cache.clone(); + let should_force_refetch = !self.connectors.prefetch_in_flight || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); self.prefetch_connectors_with_options(should_force_refetch); @@ -10072,7 +9479,7 @@ impl ChatWidget { if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( self.bottom_pane .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), - &self.connectors_cache, + &self.connectors.cache, ) { snapshot .connectors @@ -10235,7 +9642,7 @@ impl ChatWidget { // Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting. fn is_cancellable_work_active(&self) -> bool { - self.bottom_pane.is_task_running() || self.is_review_mode + self.bottom_pane.is_task_running() || self.review.is_review_mode } /// Return the markdown body width available to an active stream. @@ -10364,7 +9771,7 @@ impl ChatWidget { { collaboration_mode.reasoning_effort = Some(Some(effort)); } - if self.agent_turn_running + if self.turn_lifecycle.agent_turn_running && self.active_collaboration_mask.as_ref() != Some(&collaboration_mode) { self.add_error_message( @@ -10431,11 +9838,13 @@ impl ChatWidget { #[cfg(test)] pub(crate) fn queued_user_message_texts(&self) -> Vec { - self.rejected_steers_queue + self.input_queue + .rejected_steers_queue .iter() .map(|message| message.text.clone()) .chain( - self.queued_user_messages + self.input_queue + .queued_user_messages .iter() .map(|message| message.text.clone()), ) @@ -10502,7 +9911,7 @@ impl ChatWidget { } pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { - if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { + if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { controller.clear_queue(); } @@ -10525,9 +9934,9 @@ impl ChatWidget { ) { let mut trigger_pending_force_refetch = false; if is_final { - self.connectors_prefetch_in_flight = false; - if self.connectors_force_refetch_pending { - self.connectors_force_refetch_pending = false; + self.connectors.prefetch_in_flight = false; + if self.connectors.force_refetch_pending { + self.connectors.force_refetch_pending = false; trigger_pending_force_refetch = true; } } @@ -10535,15 +9944,15 @@ impl ChatWidget { match result { Ok(mut snapshot) => { if !is_final { - snapshot.connectors = connectors::merge_connectors_with_accessible( + snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( Vec::new(), snapshot.connectors, /*all_connectors_loaded*/ false, ); } snapshot.connectors = - connectors::with_app_enabled_state(snapshot.connectors, &self.config); - if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors_cache { + chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); + if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { let enabled_by_id: HashMap<&str, bool> = existing_snapshot .connectors .iter() @@ -10556,17 +9965,17 @@ impl ChatWidget { } } if is_final { - self.connectors_partial_snapshot = None; + self.connectors.partial_snapshot = None; self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); } else { - self.connectors_partial_snapshot = Some(snapshot.clone()); + self.connectors.partial_snapshot = Some(snapshot.clone()); } self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } Err(err) => { - let partial_snapshot = self.connectors_partial_snapshot.take(); - if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache { + let partial_snapshot = self.connectors.partial_snapshot.take(); + if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); self.bottom_pane .set_connectors_snapshot(Some(snapshot.clone())); @@ -10575,10 +9984,10 @@ impl ChatWidget { "failed to load full apps list; falling back to installed apps snapshot: {err}" ); self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } else { - self.connectors_cache = ConnectorsCacheState::Failed(err); + self.connectors.cache = ConnectorsCacheState::Failed(err); self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); } } @@ -10590,7 +9999,7 @@ impl ChatWidget { } pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { - let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else { + let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { return; }; @@ -10608,7 +10017,7 @@ impl ChatWidget { } self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } @@ -10817,13 +10226,13 @@ impl ChatWidget { /// providing an appropriate animation tick), the overlay will keep showing a stale tail while /// the main viewport updates. pub(crate) fn active_cell_transcript_key(&self) -> Option { - let cell = self.active_cell.as_ref(); + let cell = self.transcript.active_cell.as_ref(); let hook_cell = self.active_hook_cell.as_ref(); if cell.is_none() && hook_cell.is_none() { return None; } Some(ActiveCellTranscriptKey { - revision: self.active_cell_revision, + revision: self.transcript.active_cell_revision, is_stream_continuation: cell .map(|cell| cell.is_stream_continuation()) .unwrap_or(false), @@ -10843,7 +10252,7 @@ impl ChatWidget { /// mismatches between the main viewport and the transcript overlay. pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option>> { let mut lines = Vec::new(); - if let Some(cell) = self.active_cell.as_ref() { + if let Some(cell) = self.transcript.active_cell.as_ref() { lines.extend(cell.transcript_lines(width)); } if let Some(hook_cell) = self.active_hook_cell.as_ref() { @@ -10873,7 +10282,7 @@ impl ChatWidget { } fn as_renderable(&self) -> RenderableItem<'_> { - let active_cell_renderable = match &self.active_cell { + let active_cell_renderable = match &self.transcript.active_cell { Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs new file mode 100644 index 000000000000..0cd5429bafd6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -0,0 +1,20 @@ +//! Connector list cache state for `ChatWidget`. + +use crate::app_event::ConnectorsSnapshot; + +#[derive(Debug, Clone, Default)] +pub(super) enum ConnectorsCacheState { + #[default] + Uninitialized, + Loading, + Ready(ConnectorsSnapshot), + Failed(String), +} + +#[derive(Debug, Default)] +pub(super) struct ConnectorsState { + pub(super) cache: ConnectorsCacheState, + pub(super) partial_snapshot: Option, + pub(super) prefetch_in_flight: bool, + pub(super) force_refetch_pending: bool, +} diff --git a/codex-rs/tui/src/chatwidget/input_queue.rs b/codex-rs/tui/src/chatwidget/input_queue.rs new file mode 100644 index 000000000000..67a306af22d9 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/input_queue.rs @@ -0,0 +1,154 @@ +//! Queued user input and pending-steer state for `ChatWidget`. +//! +//! This module keeps the mutable input queues together so `ChatWidget` can +//! apply UI/protocol effects around a focused reducer-style state bag. + +use std::collections::VecDeque; + +use super::PendingSteer; +use super::QueuedUserMessage; +use super::UserMessage; +use super::UserMessageHistoryRecord; +use super::user_message_preview_text; + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct PendingInputPreview { + pub(super) queued_messages: Vec, + pub(super) pending_steers: Vec, + pub(super) rejected_steers: Vec, +} + +#[derive(Debug, Default)] +pub(super) struct InputQueueState { + /// User inputs queued while a turn is in progress. + pub(super) queued_user_messages: VecDeque, + /// History records for queued user messages. Slash commands such as `/goal` + /// can render history that differs from the text submitted to core, so this + /// stays in lockstep with `queued_user_messages`, with missing entries + /// treated as user-message text. + pub(super) queued_user_message_history_records: VecDeque, + /// A user turn has been submitted to core, but `TurnStarted` has not arrived yet. + pub(super) user_turn_pending_start: bool, + /// User messages that tried to steer a non-regular turn and must be retried first. + pub(super) rejected_steers_queue: VecDeque, + /// History records for rejected steers. Slash commands such as `/goal` can + /// render history that differs from the text submitted to core, so this stays + /// in lockstep with `rejected_steers_queue`, with missing entries treated as + /// user-message text. + pub(super) rejected_steer_history_records: VecDeque, + /// Steers already submitted to core but not yet committed into history. + pub(super) pending_steers: VecDeque, + /// When set, the next interrupt should resubmit all pending steers as one + /// fresh user turn instead of restoring them into the composer. + pub(super) submit_pending_steers_after_interrupt: bool, + pub(super) suppress_queue_autosend: bool, +} + +impl InputQueueState { + pub(super) fn has_queued_follow_up_messages(&self) -> bool { + !self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty() + } + + pub(super) fn clear(&mut self) { + self.queued_user_messages.clear(); + self.queued_user_message_history_records.clear(); + self.user_turn_pending_start = false; + self.rejected_steers_queue.clear(); + self.rejected_steer_history_records.clear(); + self.pending_steers.clear(); + self.submit_pending_steers_after_interrupt = false; + } + + pub(super) fn preview(&self) -> PendingInputPreview { + let queued_messages = self + .queued_user_messages + .iter() + .enumerate() + .map(|(idx, message)| { + user_message_preview_text( + message, + self.queued_user_message_history_records.get(idx), + ) + }) + .collect(); + let pending_steers = self + .pending_steers + .iter() + .map(|steer| { + user_message_preview_text(&steer.user_message, Some(&steer.history_record)) + }) + .collect(); + let rejected_steers = self + .rejected_steers_queue + .iter() + .enumerate() + .map(|(idx, message)| { + user_message_preview_text(message, self.rejected_steer_history_records.get(idx)) + }) + .collect(); + + PendingInputPreview { + queued_messages, + pending_steers, + rejected_steers, + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn preview_keeps_queue_categories_separate() { + let mut state = InputQueueState::default(); + state + .queued_user_messages + .push_back(UserMessage::from("queued").into()); + state + .rejected_steers_queue + .push_back(UserMessage::from("rejected")); + state.pending_steers.push_back(PendingSteer { + user_message: UserMessage::from("pending"), + history_record: UserMessageHistoryRecord::UserMessageText, + compare_key: crate::chatwidget::user_messages::PendingSteerCompareKey { + message: "pending".to_string(), + image_count: 0, + }, + }); + + assert_eq!( + state.preview(), + PendingInputPreview { + queued_messages: vec!["queued".to_string()], + pending_steers: vec!["pending".to_string()], + rejected_steers: vec!["rejected".to_string()], + } + ); + } + + #[test] + fn clear_resets_all_input_queues() { + let mut state = InputQueueState::default(); + state + .queued_user_messages + .push_back(UserMessage::from("queued").into()); + state + .rejected_steers_queue + .push_back(UserMessage::from("rejected")); + state.user_turn_pending_start = true; + state.submit_pending_steers_after_interrupt = true; + + state.clear(); + + assert!(state.queued_user_messages.is_empty()); + assert!(state.queued_user_message_history_records.is_empty()); + assert!(!state.user_turn_pending_start); + assert!(state.rejected_steers_queue.is_empty()); + assert!(state.rejected_steer_history_records.is_empty()); + assert!(state.pending_steers.is_empty()); + assert!(!state.submit_pending_steers_after_interrupt); + } +} diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs new file mode 100644 index 000000000000..f0e3efea0e1e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -0,0 +1,348 @@ +use super::*; + +impl ChatWidget { + pub(crate) fn handle_server_notification( + &mut self, + notification: ServerNotification, + replay_kind: Option, + ) { + if self.active_side_conversation + && replay_kind.is_none() + && matches!(notification, ServerNotification::McpServerStatusUpdated(_)) + { + return; + } + let from_replay = replay_kind.is_some(); + let is_resume_initial_replay = + matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)); + let is_retry_error = matches!( + ¬ification, + ServerNotification::Error(ErrorNotification { + will_retry: true, + .. + }) + ); + if !is_resume_initial_replay && !is_retry_error { + self.restore_retry_status_header_if_present(); + } + match notification { + ServerNotification::ThreadTokenUsageUpdated(notification) => { + self.set_token_info(Some(token_usage_info_from_app_server( + notification.token_usage, + ))); + } + ServerNotification::ThreadNameUpdated(notification) => { + match ThreadId::from_string(¬ification.thread_id) { + Ok(thread_id) => { + self.on_thread_name_updated(thread_id, notification.thread_name) + } + Err(err) => { + tracing::warn!( + thread_id = notification.thread_id, + error = %err, + "ignoring app-server ThreadNameUpdated with invalid thread_id" + ); + } + } + } + ServerNotification::ThreadGoalUpdated(notification) => { + self.on_thread_goal_updated(notification.goal, notification.turn_id); + } + ServerNotification::ThreadGoalCleared(notification) => { + self.on_thread_goal_cleared(notification.thread_id.as_str()); + } + ServerNotification::TurnStarted(notification) => { + self.turn_lifecycle.last_turn_id = Some(notification.turn.id); + self.last_non_retry_error = None; + if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { + self.on_task_started(); + } + } + ServerNotification::TurnCompleted(notification) => { + self.handle_turn_completed_notification(notification, replay_kind); + } + ServerNotification::ItemStarted(notification) => { + self.handle_item_started_notification(notification, replay_kind.is_some()); + } + ServerNotification::ItemCompleted(notification) => { + self.handle_item_completed_notification(notification, replay_kind); + } + ServerNotification::AgentMessageDelta(notification) => { + self.on_agent_message_delta(notification.delta); + } + ServerNotification::PlanDelta(notification) => self.on_plan_delta(notification.delta), + ServerNotification::ReasoningSummaryTextDelta(notification) => { + self.on_agent_reasoning_delta(notification.delta); + } + ServerNotification::ReasoningTextDelta(notification) => { + if self.config.show_raw_agent_reasoning { + self.on_agent_reasoning_delta(notification.delta); + } + } + ServerNotification::ReasoningSummaryPartAdded(_) => self.on_reasoning_section_break(), + ServerNotification::TerminalInteraction(notification) => { + self.on_terminal_interaction(notification.process_id, notification.stdin) + } + ServerNotification::CommandExecutionOutputDelta(notification) => { + self.on_exec_command_output_delta(¬ification.item_id, ¬ification.delta); + } + ServerNotification::FileChangeOutputDelta(notification) => { + self.on_patch_apply_output_delta(notification.item_id, notification.delta); + } + ServerNotification::TurnDiffUpdated(notification) => { + self.on_turn_diff(notification.diff) + } + ServerNotification::TurnPlanUpdated(notification) => { + self.on_plan_update(UpdatePlanArgs { + explanation: notification.explanation, + plan: notification + .plan + .into_iter() + .map(|step| UpdatePlanItemArg { + step: step.step, + status: match step.status { + TurnPlanStepStatus::Pending => UpdatePlanItemStatus::Pending, + TurnPlanStepStatus::InProgress => UpdatePlanItemStatus::InProgress, + TurnPlanStepStatus::Completed => UpdatePlanItemStatus::Completed, + }, + }) + .collect(), + }) + } + ServerNotification::HookStarted(notification) => { + self.on_hook_started(notification.run); + } + ServerNotification::HookCompleted(notification) => { + self.on_hook_completed(notification.run); + } + ServerNotification::Error(notification) => { + if notification.will_retry { + if !from_replay { + self.on_stream_error( + notification.error.message, + notification.error.additional_details, + ); + } + } else { + self.last_non_retry_error = Some(( + notification.turn_id.clone(), + notification.error.message.clone(), + )); + self.handle_non_retry_error( + notification.error.message, + notification.error.codex_error_info, + ); + } + } + ServerNotification::SkillsChanged(_) => { + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + } + ServerNotification::ModelRerouted(_) => {} + ServerNotification::ModelVerification(notification) => { + self.on_app_server_model_verification(¬ification.verifications) + } + ServerNotification::Warning(notification) => self.on_warning(notification.message), + ServerNotification::GuardianWarning(notification) => { + self.on_warning(notification.message) + } + ServerNotification::DeprecationNotice(notification) => { + self.on_deprecation_notice(notification.summary, notification.details) + } + ServerNotification::ConfigWarning(notification) => self.on_warning( + notification + .details + .map(|details| format!("{}: {details}", notification.summary)) + .unwrap_or(notification.summary), + ), + ServerNotification::McpServerStatusUpdated(notification) => { + self.on_mcp_server_status_updated(notification) + } + ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { + self.on_guardian_review_notification( + notification.review_id, + notification.turn_id, + notification.started_at_ms, + notification.review, + /*completion*/ None, + notification.action, + ); + } + ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { + self.on_guardian_review_notification( + notification.review_id, + notification.turn_id, + notification.started_at_ms, + notification.review, + Some((notification.completed_at_ms, notification.decision_source)), + notification.action, + ); + } + ServerNotification::ThreadClosed(_) => { + if !from_replay { + self.on_shutdown_complete(); + } + } + ServerNotification::ThreadRealtimeStarted(notification) => { + if !from_replay { + self.on_realtime_conversation_started(notification); + } + } + ServerNotification::ThreadRealtimeItemAdded(notification) => { + if !from_replay { + self.on_realtime_item_added(notification); + } + } + ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => { + if !from_replay { + self.on_realtime_output_audio_delta(notification); + } + } + ServerNotification::ThreadRealtimeError(notification) => { + if !from_replay { + self.on_realtime_error(notification); + } + } + ServerNotification::ThreadRealtimeClosed(notification) => { + if !from_replay { + self.on_realtime_conversation_closed(notification); + } + } + ServerNotification::ThreadRealtimeSdp(notification) => { + if !from_replay { + self.on_realtime_conversation_sdp(notification.sdp); + } + } + ServerNotification::ServerRequestResolved(_) + | ServerNotification::AccountUpdated(_) + | ServerNotification::AccountRateLimitsUpdated(_) + | ServerNotification::ThreadStarted(_) + | ServerNotification::ThreadStatusChanged(_) + | ServerNotification::ThreadArchived(_) + | ServerNotification::ThreadUnarchived(_) + | ServerNotification::RawResponseItemCompleted(_) + | ServerNotification::CommandExecOutputDelta(_) + | ServerNotification::ProcessOutputDelta(_) + | ServerNotification::ProcessExited(_) + | ServerNotification::FileChangePatchUpdated(_) + | ServerNotification::McpToolCallProgress(_) + | ServerNotification::McpServerOauthLoginCompleted(_) + | ServerNotification::AppListUpdated(_) + | ServerNotification::RemoteControlStatusChanged(_) + | ServerNotification::ExternalAgentConfigImportCompleted(_) + | ServerNotification::FsChanged(_) + | ServerNotification::FuzzyFileSearchSessionUpdated(_) + | ServerNotification::FuzzyFileSearchSessionCompleted(_) + | ServerNotification::ThreadRealtimeTranscriptDelta(_) + | ServerNotification::ThreadRealtimeTranscriptDone(_) + | ServerNotification::WindowsWorldWritableWarning(_) + | ServerNotification::WindowsSandboxSetupCompleted(_) + | ServerNotification::AccountLoginCompleted(_) => {} + ServerNotification::ContextCompacted(_) => {} + } + } + + pub(super) fn handle_turn_completed_notification( + &mut self, + notification: TurnCompletedNotification, + replay_kind: Option, + ) { + match notification.turn.status { + TurnStatus::Completed => { + self.last_non_retry_error = None; + self.on_task_complete( + /*last_agent_message*/ None, + notification.turn.duration_ms, + replay_kind.is_some(), + ) + } + TurnStatus::Interrupted => { + self.last_non_retry_error = None; + let reason = if self + .turn_lifecycle + .take_budget_limited(notification.turn.id.as_str()) + { + TurnAbortReason::BudgetLimited + } else { + TurnAbortReason::Interrupted + }; + self.on_interrupted_turn(reason); + } + TurnStatus::Failed => { + if let Some(error) = notification.turn.error { + if self.last_non_retry_error.as_ref() + == Some(&(notification.turn.id.clone(), error.message.clone())) + { + self.last_non_retry_error = None; + } else { + self.handle_non_retry_error(error.message, error.codex_error_info); + } + } else { + self.last_non_retry_error = None; + self.finalize_turn(); + self.request_redraw(); + self.maybe_send_next_queued_input(); + } + } + TurnStatus::InProgress => {} + } + } + + fn handle_item_started_notification( + &mut self, + notification: ItemStartedNotification, + from_replay: bool, + ) { + match notification.item { + item @ ThreadItem::CommandExecution { .. } => self.on_command_execution_started(item), + ThreadItem::FileChange { id: _, changes, .. } => { + self.on_patch_apply_begin(file_update_changes_to_display(changes)); + } + item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item), + ThreadItem::WebSearch { id, .. } => { + self.on_web_search_begin(id); + } + ThreadItem::ImageGeneration { .. } => { + self.on_image_generation_begin(); + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + } => self.on_collab_agent_tool_call(ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + prompt, + model, + reasoning_effort, + agents_states, + }), + ThreadItem::EnteredReviewMode { review, .. } => { + if !from_replay { + self.enter_review_mode_with_hint(review, /*from_replay*/ false); + } + } + _ => {} + } + } + + fn handle_item_completed_notification( + &mut self, + notification: ItemCompletedNotification, + replay_kind: Option, + ) { + self.handle_thread_item( + notification.item, + notification.turn_id, + replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay), + ); + } +} diff --git a/codex-rs/tui/src/chatwidget/review.rs b/codex-rs/tui/src/chatwidget/review.rs new file mode 100644 index 000000000000..d2b9461e7164 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/review.rs @@ -0,0 +1,13 @@ +//! Code-review flow state for `ChatWidget`. + +use crate::auto_review_denials::RecentAutoReviewDenials; +use crate::token_usage::TokenUsageInfo; + +#[derive(Debug, Default)] +pub(super) struct ReviewState { + pub(super) recent_auto_review_denials: RecentAutoReviewDenials, + /// Simple review mode flag; used to adjust layout and banners. + pub(super) is_review_mode: bool, + /// Snapshot of token usage to restore after review mode exits. + pub(super) pre_review_token_info: Option>, +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index cedc0e9686be..302480c86dae 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -1000,7 +1000,7 @@ impl ChatWidget { } fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool { - if cmd != SlashCommand::Side || !self.is_review_mode { + if cmd != SlashCommand::Side || !self.review.is_review_mode { return true; } diff --git a/codex-rs/tui/src/chatwidget/status_state.rs b/codex-rs/tui/src/chatwidget/status_state.rs new file mode 100644 index 000000000000..682e6adb9512 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_state.rs @@ -0,0 +1,179 @@ +//! Status indicator and terminal-title state for `ChatWidget`. + +use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct StatusIndicatorState { + pub(super) header: String, + pub(super) details: Option, + pub(super) details_max_lines: usize, +} + +impl StatusIndicatorState { + pub(super) fn working() -> Self { + Self { + header: String::from("Working"), + details: None, + details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, + } + } + + pub(super) fn is_guardian_review(&self) -> bool { + self.header == "Reviewing approval request" || self.header.starts_with("Reviewing ") + } +} + +/// Compact runtime states that can be rendered into the terminal title. +/// +/// This is intentionally smaller than the full status-header vocabulary. The +/// title needs short, stable labels, so callers map richer lifecycle events +/// onto one of these buckets before rendering. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) enum TerminalTitleStatusKind { + Working, + WaitingForBackgroundTerminal, + #[default] + Thinking, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct PendingGuardianReviewStatus { + entries: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PendingGuardianReviewStatusEntry { + id: String, + detail: String, +} + +impl PendingGuardianReviewStatus { + pub(super) fn start_or_update(&mut self, id: String, detail: String) { + if let Some(existing) = self.entries.iter_mut().find(|entry| entry.id == id) { + existing.detail = detail; + } else { + self.entries + .push(PendingGuardianReviewStatusEntry { id, detail }); + } + } + + pub(super) fn finish(&mut self, id: &str) -> bool { + let original_len = self.entries.len(); + self.entries.retain(|entry| entry.id != id); + self.entries.len() != original_len + } + + pub(super) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + // Guardian review status is derived from the full set of currently pending + // review entries. The generic status cache on `ChatWidget` stores whichever + // footer is currently rendered; this helper computes the guardian-specific + // footer snapshot that should replace it while reviews remain in flight. + pub(super) fn status_indicator_state(&self) -> Option { + let details = if self.entries.len() == 1 { + self.entries.first().map(|entry| entry.detail.clone()) + } else if self.entries.is_empty() { + None + } else { + let mut lines = self + .entries + .iter() + .take(3) + .map(|entry| format!("• {}", entry.detail)) + .collect::>(); + let remaining = self.entries.len().saturating_sub(3); + if remaining > 0 { + lines.push(format!("+{remaining} more")); + } + Some(lines.join("\n")) + }; + let details = details?; + let header = if self.entries.len() == 1 { + String::from("Reviewing approval request") + } else { + format!("Reviewing {} approval requests", self.entries.len()) + }; + let details_max_lines = if self.entries.len() == 1 { 1 } else { 4 }; + Some(StatusIndicatorState { + header, + details: Some(details), + details_max_lines, + }) + } +} + +#[derive(Debug)] +pub(super) struct StatusState { + pub(super) current_status: StatusIndicatorState, + pub(super) pending_guardian_review_status: PendingGuardianReviewStatus, + pub(super) terminal_title_status_kind: TerminalTitleStatusKind, + pub(super) retry_status_header: Option, + pub(super) pending_status_indicator_restore: bool, +} + +impl Default for StatusState { + fn default() -> Self { + Self { + current_status: StatusIndicatorState::working(), + pending_guardian_review_status: PendingGuardianReviewStatus::default(), + terminal_title_status_kind: TerminalTitleStatusKind::Working, + retry_status_header: None, + pending_status_indicator_restore: false, + } + } +} + +impl StatusState { + pub(super) fn set_status(&mut self, status: StatusIndicatorState) { + self.current_status = status; + } + + pub(super) fn take_retry_status_header(&mut self) -> Option { + self.retry_status_header.take() + } + + pub(super) fn remember_retry_status_header(&mut self) { + if self.retry_status_header.is_none() { + self.retry_status_header = Some(self.current_status.header.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn guardian_status_aggregates_parallel_reviews() { + let mut state = PendingGuardianReviewStatus::default(); + state.start_or_update("a".to_string(), "first".to_string()); + state.start_or_update("b".to_string(), "second".to_string()); + + assert_eq!( + state.status_indicator_state(), + Some(StatusIndicatorState { + header: "Reviewing 2 approval requests".to_string(), + details: Some("• first\n• second".to_string()), + details_max_lines: 4, + }) + ); + } + + #[test] + fn retry_status_header_is_taken_once() { + let mut state = StatusState::default(); + state.current_status.header = "Thinking".to_string(); + + state.remember_retry_status_header(); + + assert_eq!( + state.take_retry_status_header(), + Some("Thinking".to_string()) + ); + assert_eq!(state.take_retry_status_header(), None); + } +} diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index b316d0c5fbbb..12ed07cf985d 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -14,6 +14,8 @@ use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_utils_sandbox_summary::summarize_permission_profile; +use super::status_state::TerminalTitleStatusKind; + /// Items shown in the terminal title when the user has not configured a /// custom selection. Intentionally minimal: activity indicator + project name. pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"]; @@ -32,19 +34,6 @@ const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1) const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX: &str = "[ ! ] Action Required"; const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Required"; -/// Compact runtime states that can be rendered into the terminal title. -/// -/// This is intentionally smaller than the full status-header vocabulary. The -/// title needs short, stable labels, so callers map richer lifecycle events -/// onto one of these buckets before rendering. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(super) enum TerminalTitleStatusKind { - Working, - WaitingForBackgroundTerminal, - #[default] - Thinking, -} - #[derive(Debug)] /// Parsed status-surface configuration for one refresh pass. /// @@ -803,7 +792,7 @@ impl ChatWidget { return "Starting".to_string(); } - match self.terminal_title_status_kind { + match self.status_state.terminal_title_status_kind { TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => { "Ready".to_string() } @@ -879,7 +868,7 @@ impl ChatWidget { /// Formats the last `update_plan` progress snapshot for terminal-title display. pub(super) fn terminal_title_task_progress(&self) -> Option { - let (completed, total) = self.last_plan_progress?; + let (completed, total) = self.transcript.last_plan_progress?; if total == 0 { return None; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8ed8ca7db3f8..f86b896e5bc4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -149,7 +149,6 @@ pub(super) use codex_protocol::config_types::CollaborationMode; pub(super) use codex_protocol::config_types::ModeKind; pub(super) use codex_protocol::config_types::Personality; pub(super) use codex_protocol::config_types::ServiceTier; -pub(super) use codex_protocol::config_types::Settings; pub(super) use codex_protocol::models::FileSystemPermissions; pub(super) use codex_protocol::models::MessagePhase; pub(super) use codex_protocol::models::NetworkPermissions; @@ -179,7 +178,6 @@ pub(super) use serde_json::json; pub(super) use serial_test::serial; pub(super) use std::collections::BTreeMap; pub(super) use std::collections::HashMap; -pub(super) use std::collections::HashSet; pub(super) use std::path::PathBuf; pub(super) use tempfile::NamedTempFile; pub(super) use tempfile::tempdir; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 059366791e07..26e38900ce53 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -691,7 +691,7 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); assert_eq!(status.details(), None); - assert!(chat.retry_status_header.is_none()); + assert!(chat.status_state.retry_status_header.is_none()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index d16e031a8494..83c212c3072d 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -710,7 +710,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { chat.set_collaboration_mask(plan_mask); chat.on_task_started(); - chat.queued_user_messages.push_back( + chat.input_queue.queued_user_messages.push_back( UserMessage { text: "Implement the plan.".to_string(), local_images: Vec::new(), @@ -725,7 +725,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan."); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -885,7 +885,7 @@ async fn empty_enter_during_task_does_not_queue() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Ensure nothing was queued. - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -894,7 +894,9 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); chat.bottom_pane.set_task_running(/*running*/ true); - chat.pending_steers.push_back(pending_steer("queued steer")); + chat.input_queue + .pending_steers + .push_back(pending_steer("queued steer")); chat.toggle_vim_mode_and_notify(); chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); @@ -902,8 +904,8 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { chat.handle_key_event(esc); assert!(!chat.should_handle_vim_insert_escape(esc)); - assert_eq!(chat.pending_steers.len(), 1); - assert!(!chat.submit_pending_steers_after_interrupt); + assert_eq!(chat.input_queue.pending_steers.len(), 1); + assert!(!chat.input_queue.submit_pending_steers_after_interrupt); assert!(op_rx.try_recv().is_err()); chat.handle_key_event(esc); @@ -912,7 +914,7 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() { Ok(Op::Interrupt) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } - assert!(chat.submit_pending_steers_after_interrupt); + assert!(chat.input_queue.submit_pending_steers_after_interrupt); } #[tokio::test] @@ -936,14 +938,14 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { agent_turn_running: true, })); - assert!(chat.agent_turn_running); - assert!(chat.turn_sleep_inhibitor.is_turn_running()); + assert!(chat.turn_lifecycle.agent_turn_running); + assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(chat.bottom_pane.is_task_running()); chat.restore_thread_input_state(/*input_state*/ None); - assert!(!chat.agent_turn_running); - assert!(!chat.turn_sleep_inhibitor.is_turn_running()); + assert!(!chat.turn_lifecycle.agent_turn_running); + assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(!chat.bottom_pane.is_task_running()); } @@ -959,9 +961,11 @@ async fn alt_up_edits_most_recent_queued_message() { chat.bottom_pane.set_task_running(/*running*/ true); // Seed two queued messages. - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); @@ -974,9 +978,9 @@ async fn alt_up_edits_most_recent_queued_message() { "second queued".to_string() ); // And the queue should now contain only the remaining (older) item. - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "first queued" ); } @@ -989,14 +993,15 @@ async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() { chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); chat.bottom_pane.set_task_running(/*running*/ true); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("queued".to_string()).into()); chat.refresh_pending_input_preview(); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); assert!(chat.bottom_pane.composer_text().is_empty()); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); } #[tokio::test] @@ -1126,8 +1131,8 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } - assert_eq!(chat.queued_user_messages.len(), 3); - for message in chat.queued_user_messages.iter() { + assert_eq!(chat.input_queue.queued_user_messages.len(), 3); + for message in chat.input_queue.queued_user_messages.iter() { assert_eq!(message.text, "repeat me"); } } @@ -1244,9 +1249,11 @@ async fn interrupt_restores_queued_messages_into_composer() { chat.bottom_pane.set_task_running(/*running*/ true); // Queue two user messages while the task is running. - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); @@ -1260,7 +1267,7 @@ async fn interrupt_restores_queued_messages_into_composer() { ); // Queue should be cleared and no new user input should have been auto-submitted. - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), "unexpected outbound op after interrupt" @@ -1278,9 +1285,11 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); @@ -1290,7 +1299,7 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() { chat.bottom_pane.composer_text(), "first queued\nsecond queued\ncurrent draft" ); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), "unexpected outbound op after interrupt" diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 0045e7d1261e..2655c8cf9506 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -459,7 +459,7 @@ async fn exec_end_without_begin_flushes_completed_unrelated_exploring_cell() { "expected orphan end entry after flush: {second:?}" ); assert!( - chat.active_cell.is_none(), + chat.transcript.active_cell.is_none(), "both entries should be finalized" ); } @@ -706,9 +706,9 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { terminal_interaction(&mut chat, "call-1", "proc-1", ""); - assert!(chat.active_cell.is_none()); + assert!(chat.transcript.active_cell.is_none()); assert_eq!( - chat.current_status.header, + chat.status_state.current_status.header, "Waiting for background terminal" ); let status = chat @@ -728,7 +728,7 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-1a", "proc-1", ""); terminal_interaction(&mut chat, "call-wait-1b", "proc-1", ""); assert_eq!( - chat.current_status.header, + chat.status_state.current_status.header, "Waiting for background terminal" ); let status = chat @@ -794,7 +794,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-3a", "proc-3", "pwd\n"); terminal_interaction(&mut chat, "call-wait-3b", "proc-3", ""); assert_eq!( - chat.current_status.header, + chat.status_state.current_status.header, "Waiting for background terminal" ); let status = chat @@ -1022,7 +1022,7 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { ), other => panic!("expected queued user message after shell completion, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs index 85ac34ebddc6..b18b433923b1 100644 --- a/codex-rs/tui/src/chatwidget/tests/goal_validation.rs +++ b/codex-rs/tui/src/chatwidget/tests/goal_validation.rs @@ -195,7 +195,7 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i queue_composer_text_with_tab(&mut chat, &format!("/goal {objective}")); queue_composer_text_with_tab(&mut chat, "continue"); - assert_eq!(chat.queued_user_messages.len(), 2); + assert_eq!(chat.input_queue.queued_user_messages.len(), 2); complete_turn_with_message(&mut chat, "turn-1", Some("done")); @@ -219,6 +219,6 @@ async fn queued_goal_slash_command_rejects_oversized_objective_and_drains_next_i ), other => panic!("expected queued follow-up after oversized goal, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_no_submit_op(&mut op_rx); } diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 7cdc9f760be4..87ec94ef1f22 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -608,9 +608,12 @@ async fn guardian_parallel_reviews_keep_remaining_review_visible_after_denial() }, }); - assert_eq!(chat.current_status.header, "Reviewing approval request"); assert_eq!( - chat.current_status.details, + chat.status_state.current_status.header, + "Reviewing approval request" + ); + assert_eq!( + chat.status_state.current_status.details, Some("rm -rf '/tmp/guardian target 2'".to_string()) ); } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 05f967b5a41a..f11cd64843eb 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -157,180 +157,38 @@ pub(super) async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } - let prevent_idle_sleep = cfg.features.enabled(Feature::PreventIdleSleep); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); - let mut bottom = BottomPane::new(BottomPaneParams { - app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - animations_enabled: cfg.animations, - skills: None, - }); - bottom.set_collaboration_modes_enabled(/*enabled*/ true); let model_catalog = test_model_catalog(&cfg); - let reasoning_effort = None; - let base_mode = CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }, - }; - let current_collaboration_mode = base_mode; - let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); - let effective_service_tier = cfg.service_tier.clone(); - let mut widget = ChatWidget { - app_event_tx, - codex_op_target: super::CodexOpTarget::Direct(op_tx), - bottom_pane: bottom, - active_cell: None, - active_cell_revision: 0, - raw_output_mode: cfg.tui_raw_output_mode, + let common = ChatWidgetInit { config: cfg, - effective_service_tier, environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - current_collaboration_mode, - active_collaboration_mask, + frame_requester: FrameRequester::test_dummy(), + app_event_tx, + workspace_command_runner: None, + initial_user_message: None, + enhanced_keys_supported: false, has_chatgpt_account: false, model_catalog, - session_telemetry, - session_header: SessionHeader::new(resolved_model.clone()), - initial_user_message: None, + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: true, status_account_display: None, runtime_model_provider_base_url: None, - token_info: None, - rate_limit_snapshots_by_limit_id: BTreeMap::new(), - refreshing_status_outputs: Vec::new(), - next_status_refresh_request_id: 0, - plan_type: None, - codex_rate_limit_reached_type: None, - rate_limit_warnings: RateLimitWarningState::default(), - warning_display_state: WarningDisplayState::default(), - rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), - add_credits_nudge_email_in_flight: None, - adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(), - stream_controller: None, - plan_stream_controller: None, - clipboard_lease: None, - copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy, - pending_guardian_review_status: PendingGuardianReviewStatus::default(), - recent_auto_review_denials: RecentAutoReviewDenials::default(), - terminal_title_status_kind: TerminalTitleStatusKind::Working, - last_agent_markdown: None, - agent_turn_markdowns: Vec::new(), - visible_user_turn_count: 0, - copy_history_evicted_by_rollback: false, - latest_proposed_plan_markdown: None, - saw_copy_source_this_turn: false, - running_commands: HashMap::new(), - collab_agent_metadata: HashMap::new(), - pending_collab_spawn_requests: HashMap::new(), - suppressed_exec_calls: HashSet::new(), - skills_all: Vec::new(), - skills_initial_state: None, - last_unified_wait: None, - unified_exec_wait_streak: None, - turn_sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), - task_complete_pending: false, - unified_exec_processes: Vec::new(), - agent_turn_running: false, - mcp_startup_status: None, - mcp_startup_expected_servers: None, - mcp_startup_ignore_updates_until_next_start: false, - mcp_startup_allow_terminal_only_next_round: false, - mcp_startup_pending_next_round: HashMap::new(), - mcp_startup_pending_next_round_saw_starting: false, - connectors_cache: ConnectorsCacheState::default(), - connectors_partial_snapshot: None, - plugin_install_apps_needing_auth: Vec::new(), - plugin_install_auth_flow: None, - plugins_active_tab_id: None, - newly_installed_marketplace_tab_id: None, - connectors_prefetch_in_flight: false, - connectors_force_refetch_pending: false, - ide_context: super::super::ide_context::IdeContextState::default(), - plugins_cache: PluginsCacheState::default(), - plugins_fetch_state: PluginListFetchState::default(), - interrupts: InterruptManager::new(), - reasoning_buffer: String::new(), - full_reasoning_buffer: String::new(), - current_status: StatusIndicatorState::working(), - active_hook_cell: None, - retry_status_header: None, - pending_status_indicator_restore: false, - suppress_queue_autosend: false, - thread_id: None, - dismissed_plan_mode_nudge_scopes: HashSet::new(), - last_turn_id: None, - budget_limited_turn_ids: HashSet::new(), - thread_name: None, - thread_rename_block_message: None, - active_side_conversation: false, - normal_placeholder_text: "Ask Codex to do anything".to_string(), - side_placeholder_text: "Check recently modified functions for compatibility".to_string(), - forked_from: None, - interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default, - frame_requester: FrameRequester::test_dummy(), - show_welcome_banner: true, + initial_plan_type: None, + model: Some(resolved_model.clone()), startup_tooltip_override: None, - queued_user_messages: VecDeque::new(), - queued_user_message_history_records: VecDeque::new(), - user_turn_pending_start: false, - rejected_steers_queue: VecDeque::new(), - rejected_steer_history_records: VecDeque::new(), - pending_steers: VecDeque::new(), - submit_pending_steers_after_interrupt: false, - chat_keymap: crate::keymap::RuntimeKeymap::defaults().chat, - queued_message_edit_hint_binding: Some(crate::key_hint::alt(KeyCode::Up)), - suppress_session_configured_redraw: false, - suppress_initial_user_message_submit: false, - pending_notification: None, - quit_shortcut_expires_at: None, - quit_shortcut_key: None, - is_review_mode: false, - pre_review_token_info: None, - needs_final_message_separator: false, - had_work_activity: false, - saw_plan_update_this_turn: false, - saw_plan_item_this_turn: false, - last_plan_progress: None, - plan_delta_buffer: String::new(), - plan_item_active: false, - turn_runtime_metrics: RuntimeMetricsSummary::default(), - last_rendered_width: std::cell::Cell::new(None), - feedback: codex_feedback::CodexFeedback::new(), - current_rollout_path: None, - current_cwd: None, - workspace_command_runner: None, - instruction_source_paths: Vec::new(), - session_network_proxy: None, status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), - last_terminal_title: None, - last_terminal_title_requires_action: false, - terminal_title_setup_original_items: None, - terminal_title_animation_origin: Instant::now(), - status_line_project_root_name_cache: None, - status_line_branch: None, - status_line_branch_cwd: None, - status_line_branch_pending: false, - status_line_branch_lookup_complete: false, - status_line_git_summary: None, - status_line_git_summary_cwd: None, - status_line_git_summary_pending: false, - status_line_git_summary_lookup_complete: false, - current_goal_status_indicator: None, - current_goal_status: None, - goal_status_active_turn_started_at: None, - external_editor_state: ExternalEditorState::Closed, - realtime_conversation: RealtimeConversationUiState::default(), - last_rendered_user_message_display: None, - last_non_retry_error: None, + session_telemetry, }; + let mut widget = ChatWidget::new_with_op_target(common, super::CodexOpTarget::Direct(op_tx)); + widget.transcript.active_cell = None; + widget.transcript.active_cell_revision = 0; + widget.normal_placeholder_text = "Ask Codex to do anything".to_string(); + widget.side_placeholder_text = + "Check recently modified functions for compatibility".to_string(); + widget + .bottom_pane + .set_placeholder_text(widget.normal_placeholder_text.clone()); widget.set_model(&resolved_model); (widget, rx, op_rx) } @@ -527,6 +385,7 @@ pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option String { let lines = chat + .transcript .active_cell .as_ref() .expect("active cell present") @@ -1288,9 +1159,11 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi chat.bottom_pane.set_task_running(/*running*/ true); // Seed two queued messages. - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); @@ -1303,9 +1176,9 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi "second queued".to_string() ); // And the queue should now contain only the remaining (older) item. - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "first queued" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index d3cd7539861f..d77823c3cd6d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -872,8 +872,8 @@ async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() { cells.is_empty(), "expected no history cell for replayed StreamError event" ); - assert_eq!(chat.current_status.header, "Idle"); - assert!(chat.retry_status_header.is_none()); + assert_eq!(chat.status_state.current_status.header, "Idle"); + assert!(chat.status_state.retry_status_header.is_none()); assert!(chat.bottom_pane.status_widget().is_none()); } @@ -900,7 +900,7 @@ async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_heade .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); assert_eq!(status.details(), None); - assert!(chat.retry_status_header.is_none()); + assert!(chat.status_state.retry_status_header.is_none()); } #[tokio::test] @@ -922,5 +922,5 @@ async fn stream_recovery_restores_previous_status_header() { .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); assert_eq!(status.details(), None); - assert!(chat.retry_status_header.is_none()); + assert!(chat.status_state.retry_status_header.is_none()); } diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index bd8a1800f1ef..9b8540e5aa10 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -701,7 +701,7 @@ async fn submit_user_message_with_mode_errors_when_mode_changes_during_running_t chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); let rendered = drain_insert_history(&mut rx) .iter() @@ -749,7 +749,7 @@ async fn submit_user_message_with_mode_allows_same_mode_during_running_turn() { chat.submit_user_message_with_mode("Continue planning.".to_string(), plan_mask); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: @@ -783,7 +783,7 @@ async fn submit_user_message_with_mode_submits_when_plan_stream_is_not_active() chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode); assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode { mode, .. }), @@ -1144,7 +1144,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { chat.submit_user_message(UserMessage::from("queued while compacting")); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, @@ -1164,7 +1164,7 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { }), ); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!( chat.queued_user_message_texts(), vec!["queued while compacting"] @@ -1278,7 +1278,7 @@ async fn enter_submits_when_plan_stream_is_not_active() { .set_composer_text("submitted immediately".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); match next_submit_op(&mut op_rx) { Op::UserTurn { personality: Some(Personality::Pragmatic), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index bb2bc1a96727..cce83ad6d8a9 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1338,7 +1338,7 @@ async fn apps_popup_stays_loading_until_final_snapshot_updates() { ); chat.add_connectors_output(); assert!( - chat.connectors_prefetch_in_flight, + chat.connectors.prefetch_in_flight, "expected /apps to trigger a forced connectors refresh" ); @@ -1475,7 +1475,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors ); @@ -1616,8 +1616,8 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc .enable(Feature::Apps) .expect("test config should allow feature update"); chat.bottom_pane.set_connectors_enabled(/*enabled*/ true); - chat.connectors_prefetch_in_flight = true; - chat.connectors_force_refetch_pending = true; + chat.connectors.prefetch_in_flight = true; + chat.connectors.force_refetch_pending = true; let full_connectors = vec![AppInfo { id: "unit_test_apps_refresh_failure_pending_connector".to_string(), @@ -1634,7 +1634,7 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc is_enabled: true, plugin_display_names: Vec::new(), }]; - chat.connectors_cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { + chat.connectors.cache = ConnectorsCacheState::Ready(ConnectorsSnapshot { connectors: full_connectors.clone(), }); @@ -1643,10 +1643,10 @@ async fn apps_refresh_failure_with_cached_snapshot_triggers_pending_force_refetc /*is_final*/ true, ); - assert!(chat.connectors_prefetch_in_flight); - assert!(!chat.connectors_force_refetch_pending); + assert!(chat.connectors.prefetch_in_flight); + assert!(!chat.connectors.force_refetch_pending); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors ); } @@ -1740,7 +1740,7 @@ async fn apps_popup_keeps_existing_full_snapshot_while_partial_refresh_loads() { ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors ); @@ -1799,7 +1799,7 @@ async fn apps_refresh_failure_without_full_snapshot_falls_back_to_installed_apps ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot.connectors.len() == 1 ); @@ -1900,7 +1900,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot .connectors @@ -1966,7 +1966,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot .connectors @@ -2030,7 +2030,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_without_user_ ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot .connectors @@ -2101,7 +2101,7 @@ async fn apps_refresh_preserves_toggled_enabled_state() { ); assert_matches!( - &chat.connectors_cache, + &chat.connectors.cache, ConnectorsCacheState::Ready(snapshot) if snapshot .connectors diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index f59e880dacc1..18798fe5a59b 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -29,7 +29,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { )]; let existing_images = vec![PathBuf::from("/tmp/existing.png")]; - chat.queued_user_messages.push_back( + chat.input_queue.queued_user_messages.push_back( UserMessage { text: first_text, local_images: vec![LocalImageAttachment { @@ -42,7 +42,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { } .into(), ); - chat.queued_user_messages.push_back( + chat.input_queue.queued_user_messages.push_back( UserMessage { text: second_text, local_images: vec![LocalImageAttachment { @@ -108,7 +108,7 @@ async fn entered_review_mode_uses_request_hint() { let cells = drain_insert_history(&mut rx); let banner = lines_to_single_string(cells.last().expect("review banner")); assert_eq!(banner, ">> Code review started: feature branch <<\n"); - assert!(chat.is_review_mode); + assert!(chat.review.is_review_mode); } /// Entering review mode renders the current changes banner when requested. @@ -121,7 +121,7 @@ async fn entered_review_mode_defaults_to_current_changes_banner() { let cells = drain_insert_history(&mut rx); let banner = lines_to_single_string(cells.last().expect("review banner")); assert_eq!(banner, ">> Code review started: current changes <<\n"); - assert!(chat.is_review_mode); + assert!(chat.review.is_review_mode); } #[tokio::test] @@ -200,13 +200,14 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages handle_turn_started(&mut chat, "turn-1"); handle_entered_review_mode(&mut chat, "feature branch"); let _ = drain_insert_history(&mut rx); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("queued later").into()); chat.submit_user_message(UserMessage::from("review follow-up one")); chat.submit_user_message(UserMessage::from("review follow-up two")); - assert_eq!(chat.pending_steers.len(), 2); + assert_eq!(chat.input_queue.pending_steers.len(), 2); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, @@ -243,7 +244,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages }), ); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!( chat.queued_user_message_texts(), vec![ @@ -328,7 +329,7 @@ async fn review_restores_context_window_indicator() { let _ = drain_insert_history(&mut rx); assert_eq!(chat.bottom_pane.context_window_percent(), Some(30)); - assert!(!chat.is_review_mode); + assert!(!chat.review.is_review_mode); } #[tokio::test] @@ -367,13 +368,18 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ chat.queued_user_message_texts(), vec!["already rejected", "queued draft"] ); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "pending steer" ); assert_eq!( - chat.pending_steers.front().unwrap().compare_key, + chat.input_queue.pending_steers.front().unwrap().compare_key, expected_compare_key ); } @@ -395,12 +401,12 @@ async fn steer_enter_queues_while_plan_stream_is_active() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "queued submission" ); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_no_submit_op(&mut op_rx); assert!(drain_insert_history(&mut rx).is_empty()); } @@ -415,10 +421,15 @@ async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming .set_composer_text("queued while running".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.queued_user_messages.is_empty()); - assert_eq!(chat.pending_steers.len(), 1); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "queued while running" ); match next_submit_op(&mut op_rx) { @@ -429,7 +440,7 @@ async fn steer_enter_uses_pending_steers_while_turn_is_running_without_streaming complete_user_message(&mut chat, "user-1", "queued while running"); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); assert!(lines_to_single_string(&inserted[0]).contains("queued while running")); @@ -451,10 +462,15 @@ async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.queued_user_messages.is_empty()); - assert_eq!(chat.pending_steers.len(), 1); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "queued while streaming" ); match next_submit_op(&mut op_rx) { @@ -465,7 +481,7 @@ async fn steer_enter_uses_pending_steers_while_final_answer_stream_is_active() { complete_user_message(&mut chat, "user-1", "queued while streaming"); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); assert!(lines_to_single_string(&inserted[0]).contains("queued while streaming")); @@ -485,23 +501,32 @@ async fn failed_pending_steer_submit_does_not_add_pending_preview() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.pending_steers.is_empty()); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert!(drain_insert_history(&mut rx).is_empty()); } #[tokio::test] async fn item_completed_only_pops_front_pending_steer() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.pending_steers.push_back(pending_steer("first")); - chat.pending_steers.push_back(pending_steer("second")); + chat.input_queue + .pending_steers + .push_back(pending_steer("first")); + chat.input_queue + .pending_steers + .push_back(pending_steer("second")); chat.refresh_pending_input_preview(); complete_user_message(&mut chat, "user-other", "other"); - assert_eq!(chat.pending_steers.len(), 2); + assert_eq!(chat.input_queue.pending_steers.len(), 2); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "first" ); let inserted = drain_insert_history(&mut rx); @@ -510,9 +535,14 @@ async fn item_completed_only_pops_front_pending_steer() { complete_user_message(&mut chat, "user-first", "first"); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "second" ); let inserted = drain_insert_history(&mut rx); @@ -553,8 +583,8 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() other => panic!("expected Op::UserTurn, got {other:?}"), } - assert_eq!(chat.pending_steers.len(), 1); - let pending = chat.pending_steers.front().unwrap(); + assert_eq!(chat.input_queue.pending_steers.len(), 1); + let pending = chat.input_queue.pending_steers.front().unwrap(); assert_eq!(pending.user_message.local_images.len(), 1); assert_eq!(pending.user_message.text_elements.len(), 1); assert_eq!(pending.compare_key.message, text); @@ -574,7 +604,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() ], ); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { @@ -619,14 +649,24 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() .set_composer_text("second follow-up".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.queued_user_messages.is_empty()); - assert_eq!(chat.pending_steers.len(), 2); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!(chat.input_queue.pending_steers.len(), 2); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "first follow-up" ); assert_eq!( - chat.pending_steers.back().unwrap().user_message.text, + chat.input_queue + .pending_steers + .back() + .unwrap() + .user_message + .text, "second follow-up" ); @@ -656,9 +696,14 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() complete_user_message(&mut chat, "user-1", "first follow-up"); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "second follow-up" ); let first_insert = drain_insert_history(&mut rx); @@ -667,7 +712,7 @@ async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() complete_user_message(&mut chat, "user-2", "second follow-up"); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); let second_insert = drain_insert_history(&mut rx); assert_eq!(second_insert.len(), 1); assert!(lines_to_single_string(&second_insert[0]).contains("second follow-up")); @@ -691,7 +736,7 @@ async fn manual_interrupt_restores_pending_steers_to_composer() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, @@ -706,7 +751,7 @@ async fn manual_interrupt_restores_pending_steers_to_composer() { chat.on_interrupted_turn(TurnAbortReason::Interrupted); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!(chat.bottom_pane.composer_text(), "queued while streaming"); assert_no_submit_op(&mut op_rx); @@ -754,7 +799,8 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d other => panic!("expected Op::UserTurn, got {other:?}"), } - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("queued draft".to_string()).into()); chat.refresh_pending_input_preview(); chat.bottom_pane @@ -776,11 +822,11 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d other => panic!("expected merged pending steers to submit, got {other:?}"), } - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!(chat.bottom_pane.composer_text(), "still editing"); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "queued draft" ); @@ -875,7 +921,8 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() { chat.bottom_pane .set_composer_text("pending steer".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("queued draft".to_string()).into()); chat.refresh_pending_input_preview(); @@ -893,8 +940,8 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() { chat.on_interrupted_turn(TurnAbortReason::Interrupted); - assert!(chat.pending_steers.is_empty()); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_eq!( chat.bottom_pane.composer_text(), "pending steer @@ -1233,14 +1280,15 @@ async fn direct_budget_limited_turn_uses_budget_message_snapshot() { #[tokio::test] async fn budget_limited_turn_restores_queued_input_without_submitting() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.queued_user_messages + chat.input_queue + .queued_user_messages .push_back(UserMessage::from("follow-up after budget stop").into()); chat.refresh_pending_input_preview(); handle_turn_started(&mut chat, "turn-1"); handle_budget_limited_turn(&mut chat, "turn-1"); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_eq!( chat.bottom_pane.composer_text(), "follow-up after budget stop" @@ -1255,8 +1303,10 @@ async fn budget_limited_turn_restores_queued_input_without_submitting() { async fn interrupted_turn_pending_steers_message_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.pending_steers.push_back(pending_steer("steer 1")); - chat.submit_pending_steers_after_interrupt = true; + chat.input_queue + .pending_steers + .push_back(pending_steer("steer 1")); + chat.input_queue.submit_pending_steers_after_interrupt = true; handle_turn_started(&mut chat, "turn-1"); @@ -1358,10 +1408,15 @@ async fn enter_submits_steer_while_review_is_running() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.queued_user_messages.is_empty()); - assert_eq!(chat.pending_steers.len(), 1); + assert!(chat.input_queue.queued_user_messages.is_empty()); + assert_eq!(chat.input_queue.pending_steers.len(), 1); assert_eq!( - chat.pending_steers.front().unwrap().user_message.text, + chat.input_queue + .pending_steers + .front() + .unwrap() + .user_message + .text, "Steer submitted while /review was running." ); match next_submit_op(&mut op_rx) { diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 8aaed6d366fd..2f2a6d33561b 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -149,7 +149,7 @@ async fn slash_side_is_rejected_for_side_threads() { #[tokio::test] async fn slash_side_is_rejected_during_review_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.is_review_mode = true; + chat.review.is_review_mode = true; chat.dispatch_command(SlashCommand::Side); @@ -216,7 +216,7 @@ async fn slash_side_without_args_starts_empty_side_conversation() { op_rx.try_recv().is_err(), "bare /side should not submit an op on the parent thread" ); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index dc8f2e7cb2d2..f2b1addab608 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -81,10 +81,10 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(chat.pending_steers.is_empty()); - assert_eq!(chat.queued_user_messages.len(), 1); + assert!(chat.input_queue.pending_steers.is_empty()); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "queued before compact turn start" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); @@ -98,9 +98,13 @@ async fn queued_slash_compact_dispatches_after_active_turn() { queue_composer_text_with_tab(&mut chat, "/compact"); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().action, + chat.input_queue + .queued_user_messages + .front() + .unwrap() + .action, QueuedInputAction::ParseSlash ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); @@ -160,9 +164,13 @@ async fn queued_bang_shell_dispatches_after_active_turn() { queue_composer_text_with_tab(&mut chat, "!echo hi"); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().action, + chat.input_queue + .queued_user_messages + .front() + .unwrap() + .action, QueuedInputAction::RunShell ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); @@ -174,7 +182,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() { other => panic!("expected queued shell command op, got {other:?}"), } assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -211,7 +219,7 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu ), other => panic!("expected queued message after empty shell command, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -230,7 +238,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { other => panic!("expected queued shell command op, got {other:?}"), } assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); let begin = begin_exec_with_source( &mut chat, @@ -250,7 +258,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { ), other => panic!("expected queued message after shell completion, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { @@ -263,7 +271,7 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ complete_turn_with_message(&mut chat, "turn-1", Some("done")); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains(expected_popup_text), @@ -283,7 +291,7 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ ), other => panic!("expected queued message after cancelling {command}, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -322,7 +330,7 @@ async fn queued_slash_menu_selection_drains_next_input() { ), other => panic!("expected queued message after permissions selection, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -337,7 +345,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() { complete_turn_with_message(&mut chat, "turn-1", Some("done")); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread")); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); @@ -373,7 +381,7 @@ async fn queued_bare_rename_drains_next_input_after_name_update() { ), other => panic!("expected queued message after /rename, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -419,9 +427,9 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { let input_state = chat.capture_thread_input_state().unwrap(); assert!(input_state.user_turn_pending_start); chat.restore_thread_input_state(/*input_state*/ None); - assert!(!chat.user_turn_pending_start); + assert!(!chat.input_queue.user_turn_pending_start); chat.restore_thread_input_state(Some(input_state)); - assert!(chat.user_turn_pending_start); + assert!(chat.input_queue.user_turn_pending_start); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] @@ -456,7 +464,7 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { ), other => panic!("expected second queued message after turn complete, got {other:?}"), } - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -481,7 +489,7 @@ async fn queued_unknown_slash_reports_error_when_dequeued() { rendered.contains("Unrecognized command '/does-not-exist'"), "expected delayed slash error, got {rendered:?}" ); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); } #[tokio::test] @@ -728,7 +736,7 @@ async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() { let command = "/goal improve benchmark coverage"; submit_composer_text(&mut chat, command); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); let thread_id = ThreadId::new(); @@ -1355,7 +1363,7 @@ async fn copy_shortcut_can_be_remapped() { #[tokio::test] async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.last_agent_markdown = Some("copy me".to_string()); + chat.transcript.last_agent_markdown = Some("copy me".to_string()); chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); @@ -1478,7 +1486,7 @@ async fn queued_follow_up_suppresses_agent_turn_complete_notification() { complete_turn_with_message(&mut chat, "turn-1", Some("Still working")); assert_matches!(chat.pending_notification, None); - assert!(chat.queued_user_messages.is_empty()); + assert!(chat.input_queue.queued_user_messages.is_empty()); assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. }); } diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index df88bcec93a6..00972ff8450f 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1000,9 +1000,9 @@ async fn streaming_final_answer_keeps_task_running_state() { .set_composer_text("queued submission".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( - chat.queued_user_messages.front().unwrap().text, + chat.input_queue.queued_user_messages.front().unwrap().text, "queued submission" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); @@ -1059,7 +1059,7 @@ async fn final_answer_completion_restores_status_indicator_for_pending_steer() { ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(chat.pending_steers.len(), 1); + assert_eq!(chat.input_queue.pending_steers.len(), 1); let items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), @@ -1088,7 +1088,7 @@ async fn final_answer_completion_restores_status_indicator_for_pending_steer() { "Please summarize the rest more briefly.", ); - assert!(chat.pending_steers.is_empty()); + assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!(chat.bottom_pane.status_indicator_visible(), true); assert_eq!(chat.bottom_pane.is_task_running(), true); } @@ -1874,7 +1874,9 @@ async fn session_configured_clears_goal_status_footer() { usage: Some("40K / 50K".to_string()) }) ); - chat.budget_limited_turn_ids.insert("turn-1".to_string()); + chat.turn_lifecycle + .budget_limited_turn_ids + .insert("turn-1".to_string()); let rollout_file = NamedTempFile::new().unwrap(); chat.handle_thread_session(crate::session_state::ThreadSessionState { @@ -1898,7 +1900,7 @@ async fn session_configured_clears_goal_status_footer() { }); assert_eq!(chat.current_goal_status_indicator, None); - assert!(chat.budget_limited_turn_ids.is_empty()); + assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty()); } #[tokio::test] @@ -1927,7 +1929,7 @@ async fn thread_goal_update_for_other_thread_is_ignored() { assert_eq!(chat.current_goal_status_indicator, None); assert!(chat.current_goal_status.is_none()); - assert!(chat.budget_limited_turn_ids.is_empty()); + assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty()); } #[test] @@ -2584,7 +2586,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { Some("checking command policy"), ), ); - assert_eq!(chat.current_status.header, "Thinking"); + assert_eq!(chat.status_state.current_status.header, "Thinking"); reveal_running_hooks(&mut chat); let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", ""); @@ -2596,7 +2598,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { Some("checking output policy"), ), ); - assert_eq!(chat.current_status.header, "Thinking"); + assert_eq!(chat.status_state.current_status.header, "Thinking"); reveal_running_hooks(&mut chat); let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", ""); @@ -2609,7 +2611,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { Vec::new(), ), ); - assert_eq!(chat.current_status.header, "Thinking"); + assert_eq!(chat.status_state.current_status.header, "Thinking"); let older_completed_snapshot = hook_live_and_history_snapshot(&chat, "pre completed lingering", ""); expire_quiet_hook_linger(&mut chat); @@ -2625,7 +2627,7 @@ async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() { Vec::new(), ), ); - assert_eq!(chat.current_status.header, "Thinking"); + assert_eq!(chat.status_state.current_status.header, "Thinking"); assert!(chat.bottom_pane.status_indicator_visible()); assert!(drain_insert_history(&mut rx).is_empty()); let all_completed_lingering_snapshot = diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index c11d0167516b..8837f04d8d8d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -64,7 +64,7 @@ async fn status_surface_preview_lines_live_only_snapshot() { cache_project_root(&mut chat, "preview-live-root"); chat.status_line_branch = Some("feature/live-preview-branch".to_string()); chat.thread_name = Some("Live preview thread".to_string()); - chat.last_plan_progress = Some((2, 5)); + chat.transcript.last_plan_progress = Some((2, 5)); let snapshot = combined_preview_snapshot( &mut chat, @@ -202,7 +202,7 @@ async fn terminal_title_setup_popup_live_only_snapshot() { cache_project_root(&mut chat, "preview-live-root"); chat.status_line_branch = Some("feature/live-preview-branch".to_string()); chat.thread_name = Some("Live preview thread".to_string()); - chat.last_plan_progress = Some((2, 5)); + chat.transcript.last_plan_progress = Some((2, 5)); chat.config.tui_terminal_title = Some(vec![ "project-name".to_string(), "thread-title".to_string(), diff --git a/codex-rs/tui/src/chatwidget/transcript.rs b/codex-rs/tui/src/chatwidget/transcript.rs new file mode 100644 index 000000000000..22b046728bb5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/transcript.rs @@ -0,0 +1,151 @@ +//! Transcript and active-cell bookkeeping for `ChatWidget`. + +use super::HistoryCell; +use super::MAX_AGENT_COPY_HISTORY; + +#[derive(Debug)] +pub(super) struct AgentTurnMarkdown { + pub(super) user_turn_count: usize, + pub(super) markdown: String, +} + +#[derive(Default)] +pub(super) struct TranscriptState { + pub(super) active_cell: Option>, + /// Monotonic-ish counter used to invalidate transcript overlay caching. + pub(super) active_cell_revision: u64, + /// Raw markdown of the most recently completed agent response that + /// survived any local thread rollback. + pub(super) last_agent_markdown: Option, + /// Copyable agent responses keyed by the number of visible user turns at + /// the time the response completed. + pub(super) agent_turn_markdowns: Vec, + /// Number of user turns currently reflected in the visible transcript. + pub(super) visible_user_turn_count: usize, + /// True when rollback discarded the requested copy source because it was + /// older than the retained copy history. + pub(super) copy_history_evicted_by_rollback: bool, + /// Raw markdown of the most recently completed proposed plan. + pub(super) latest_proposed_plan_markdown: Option, + /// Whether this turn already produced a copyable response. + pub(super) saw_copy_source_this_turn: bool, + /// Whether the next streamed assistant content should be preceded by a final message separator. + pub(super) needs_final_message_separator: bool, + /// Whether the current turn performed "work" (exec commands, MCP tool calls, patch applications). + pub(super) had_work_activity: bool, + /// Whether the current turn emitted a plan update. + pub(super) saw_plan_update_this_turn: bool, + /// Whether the current turn emitted a proposed plan item that has not been superseded by a + /// later steer. + pub(super) saw_plan_item_this_turn: bool, + /// Latest `update_plan` checklist task counts for terminal-title rendering. + pub(super) last_plan_progress: Option<(usize, usize)>, + /// Incremental buffer for streamed plan content. + pub(super) plan_delta_buffer: String, + /// True while a plan item is streaming. + pub(super) plan_item_active: bool, +} + +impl TranscriptState { + pub(super) fn new(active_cell: Option>) -> Self { + Self { + active_cell, + ..Self::default() + } + } + + pub(super) fn bump_active_cell_revision(&mut self) { + // Wrapping avoids overflow; wraparound would require 2^64 bumps and at + // worst causes a one-time cache-key collision. + self.active_cell_revision = self.active_cell_revision.wrapping_add(1); + } + + pub(super) fn record_agent_markdown(&mut self, markdown: String) { + match self.agent_turn_markdowns.last_mut() { + Some(entry) if entry.user_turn_count == self.visible_user_turn_count => { + entry.markdown = markdown.clone(); + } + _ => { + self.agent_turn_markdowns.push(AgentTurnMarkdown { + user_turn_count: self.visible_user_turn_count, + markdown: markdown.clone(), + }); + if self.agent_turn_markdowns.len() > MAX_AGENT_COPY_HISTORY { + self.agent_turn_markdowns.remove(0); + } + } + } + self.last_agent_markdown = Some(markdown); + self.copy_history_evicted_by_rollback = false; + self.saw_copy_source_this_turn = true; + } + + pub(super) fn record_visible_user_turn(&mut self) { + self.visible_user_turn_count = self.visible_user_turn_count.saturating_add(1); + } + + pub(super) fn reset_copy_history(&mut self) { + self.last_agent_markdown = None; + self.agent_turn_markdowns.clear(); + self.visible_user_turn_count = 0; + self.copy_history_evicted_by_rollback = false; + self.saw_copy_source_this_turn = false; + } + + pub(super) fn truncate_copy_history_to_user_turn_count(&mut self, user_turn_count: usize) { + self.visible_user_turn_count = user_turn_count; + let had_copy_history = !self.agent_turn_markdowns.is_empty(); + self.agent_turn_markdowns + .retain(|entry| entry.user_turn_count <= user_turn_count); + self.last_agent_markdown = self + .agent_turn_markdowns + .last() + .map(|entry| entry.markdown.clone()); + self.copy_history_evicted_by_rollback = + had_copy_history && self.last_agent_markdown.is_none(); + self.saw_copy_source_this_turn = false; + } + + pub(super) fn reset_turn_flags(&mut self) { + self.saw_copy_source_this_turn = false; + self.saw_plan_update_this_turn = false; + self.saw_plan_item_this_turn = false; + self.had_work_activity = false; + self.latest_proposed_plan_markdown = None; + self.plan_delta_buffer.clear(); + self.plan_item_active = false; + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn active_cell_revision_wraps() { + let mut state = TranscriptState { + active_cell_revision: u64::MAX, + ..TranscriptState::default() + }; + + state.bump_active_cell_revision(); + + assert_eq!(state.active_cell_revision, 0); + } + + #[test] + fn copy_history_tracks_latest_visible_turn() { + let mut state = TranscriptState::default(); + state.record_visible_user_turn(); + state.record_agent_markdown("first".to_string()); + state.record_visible_user_turn(); + state.record_agent_markdown("second".to_string()); + + state.truncate_copy_history_to_user_turn_count(/*user_turn_count*/ 1); + + assert_eq!(state.last_agent_markdown.as_deref(), Some("first")); + assert!(!state.copy_history_evicted_by_rollback); + } +} diff --git a/codex-rs/tui/src/chatwidget/turn_lifecycle.rs b/codex-rs/tui/src/chatwidget/turn_lifecycle.rs new file mode 100644 index 000000000000..6ecc29e02f7a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/turn_lifecycle.rs @@ -0,0 +1,97 @@ +//! Agent-turn lifecycle state for `ChatWidget`. + +use std::collections::HashSet; +use std::time::Instant; + +use codex_utils_sleep_inhibitor::SleepInhibitor; + +#[derive(Debug)] +pub(super) struct TurnLifecycleState { + pub(super) sleep_inhibitor: SleepInhibitor, + /// Tracks whether codex-core currently considers an agent turn to be in progress. + pub(super) agent_turn_running: bool, + pub(super) last_turn_id: Option, + pub(super) budget_limited_turn_ids: HashSet, + pub(super) goal_status_active_turn_started_at: Option, +} + +impl TurnLifecycleState { + pub(super) fn new(prevent_idle_sleep: bool) -> Self { + Self { + sleep_inhibitor: SleepInhibitor::new(prevent_idle_sleep), + agent_turn_running: false, + last_turn_id: None, + budget_limited_turn_ids: HashSet::new(), + goal_status_active_turn_started_at: None, + } + } + + pub(super) fn start(&mut self, now: Instant) { + self.agent_turn_running = true; + self.goal_status_active_turn_started_at = Some(now); + self.sleep_inhibitor.set_turn_running(/*turn_running*/ true); + } + + pub(super) fn finish(&mut self) { + self.agent_turn_running = false; + self.goal_status_active_turn_started_at = None; + self.sleep_inhibitor + .set_turn_running(/*turn_running*/ false); + } + + pub(super) fn restore_running(&mut self, running: bool, now: Instant) { + self.agent_turn_running = running; + self.goal_status_active_turn_started_at = running.then_some(now); + self.sleep_inhibitor.set_turn_running(running); + } + + pub(super) fn reset_thread(&mut self) { + self.finish(); + self.last_turn_id = None; + self.budget_limited_turn_ids.clear(); + } + + pub(super) fn set_prevent_idle_sleep(&mut self, enabled: bool) { + self.sleep_inhibitor = SleepInhibitor::new(enabled); + self.sleep_inhibitor + .set_turn_running(self.agent_turn_running); + } + + pub(super) fn mark_budget_limited(&mut self, turn_id: String) { + self.budget_limited_turn_ids.insert(turn_id); + } + + pub(super) fn take_budget_limited(&mut self, turn_id: &str) -> bool { + self.budget_limited_turn_ids.remove(turn_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_and_finish_update_running_state() { + let mut state = TurnLifecycleState::new(/*prevent_idle_sleep*/ false); + + state.start(Instant::now()); + assert!(state.agent_turn_running); + assert!(state.goal_status_active_turn_started_at.is_some()); + assert!(state.sleep_inhibitor.is_turn_running()); + + state.finish(); + assert!(!state.agent_turn_running); + assert!(state.goal_status_active_turn_started_at.is_none()); + assert!(!state.sleep_inhibitor.is_turn_running()); + } + + #[test] + fn budget_limited_turn_ids_are_consumed() { + let mut state = TurnLifecycleState::new(/*prevent_idle_sleep*/ false); + + state.mark_budget_limited("turn-1".to_string()); + + assert!(state.take_budget_limited("turn-1")); + assert!(!state.take_budget_limited("turn-1")); + } +}