Split ChatWidget state into focused modules (#21866)

## Summary

`ChatWidget` has been carrying several independent domains in one large
state bag: transcript bookkeeping, turn lifecycle, queued input, status
surfaces, connectors, review mode, and protocol dispatch. That makes
otherwise-local changes hard to reason about because unrelated fields
and side effects live beside each other in `chatwidget.rs`.

This is the first cleanup PR in a larger decomposition effort. It does
not try to make `chatwidget.rs` small in one sweep; instead, it
establishes focused state boundaries that later handler, popup,
rendering, and effect-synchronization extractions can build on.

This PR keeps `ChatWidget` as the composition layer while moving focused
state into smaller `codex-tui` modules. The widget still owns effects
that touch the bottom pane, app events, command submission, redraw
scheduling, and terminal-title updates.

## Changes

- Add focused state modules under `codex-rs/tui/src/chatwidget/` for
input queues, turn lifecycle, transcript bookkeeping, status state,
connectors, review mode, and app-server protocol dispatch.
- Update `ChatWidget` to hold grouped state structs and route
input/lifecycle/status operations through those focused helpers.
- Move app-server notification dispatch into `chatwidget/protocol.rs`
while leaving feature handlers and side effects on `ChatWidget`.
- Replace the large manual `ChatWidget` test literal with the normal
constructor plus narrow test overrides, so future state moves do not
require every field to be restated in test setup.
- Update existing tests to access the new grouped state or narrower
helpers without changing snapshot behavior.

## Longer-term direction

Follow-up PRs can continue shrinking `chatwidget.rs` by moving behavior,
not just state, into focused modules:

- Extract input/submission flow, turn/stream handling, and tool-cell
lifecycles into domain modules that call the new state reducers.
- Move popup/settings builders and rendering helpers out of the main
widget file so `ChatWidget` stays focused on composition.
- Reduce direct `BottomPane` mutation by applying domain-specific sync
outputs at clearer boundaries.
This commit is contained in:
Eric Traut
2026-05-09 15:16:01 -07:00
committed by GitHub
Unverified
parent 90c0bec50c
commit 789b7e39dc
25 changed files with 1587 additions and 1279 deletions
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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<ConnectorsSnapshot>,
pub(super) prefetch_in_flight: bool,
pub(super) force_refetch_pending: bool,
}
+154
View File
@@ -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<String>,
pub(super) pending_steers: Vec<String>,
pub(super) rejected_steers: Vec<String>,
}
#[derive(Debug, Default)]
pub(super) struct InputQueueState {
/// User inputs queued while a turn is in progress.
pub(super) queued_user_messages: VecDeque<QueuedUserMessage>,
/// 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<UserMessageHistoryRecord>,
/// 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<UserMessage>,
/// 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<UserMessageHistoryRecord>,
/// Steers already submitted to core but not yet committed into history.
pub(super) pending_steers: VecDeque<PendingSteer>,
/// 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);
}
}
+348
View File
@@ -0,0 +1,348 @@
use super::*;
impl ChatWidget {
pub(crate) fn handle_server_notification(
&mut self,
notification: ServerNotification,
replay_kind: Option<ReplayKind>,
) {
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!(
&notification,
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(&notification.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(&notification.item_id, &notification.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(&notification.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<ReplayKind>,
) {
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<ReplayKind>,
) {
self.handle_thread_item(
notification.item,
notification.turn_id,
replay_kind.map_or(ThreadItemRenderSource::Live, ThreadItemRenderSource::Replay),
);
}
}
+13
View File
@@ -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<Option<TokenUsageInfo>>,
}
@@ -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;
}
+179
View File
@@ -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<String>,
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<PendingGuardianReviewStatusEntry>,
}
#[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<StatusIndicatorState> {
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::<Vec<_>>();
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<String>,
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<String> {
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);
}
}
+4 -15
View File
@@ -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<String> {
let (completed, total) = self.last_plan_progress?;
let (completed, total) = self.transcript.last_plan_progress?;
if total == 0 {
return None;
}
-2
View File
@@ -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;
@@ -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]
@@ -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"
@@ -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]
@@ -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);
}
@@ -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())
);
}
+40 -167
View File
@@ -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<TokenUsageI
codex_app_server_protocol::ThreadTokenUsageUpdatedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -559,6 +418,7 @@ pub(super) fn handle_error(
will_retry: false,
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -591,6 +451,7 @@ pub(super) fn handle_stream_error_with_replay(
will_retry: true,
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -617,6 +478,7 @@ pub(super) fn handle_model_verification(
ServerNotification::ModelVerification(ModelVerificationNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -632,6 +494,7 @@ pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -648,6 +511,7 @@ pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl In
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -664,6 +528,7 @@ pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) {
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -683,6 +548,7 @@ pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Int
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -712,6 +578,7 @@ pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) {
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -989,6 +856,7 @@ pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1010,6 +878,7 @@ pub(super) fn terminal_interaction(
codex_app_server_protocol::TerminalInteractionNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1153,7 +1022,7 @@ pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) {
}
pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) {
chat.budget_limited_turn_ids.insert(turn_id.to_string());
chat.turn_lifecycle.mark_budget_limited(turn_id.to_string());
handle_turn_interrupted(chat, turn_id);
}
@@ -1215,6 +1084,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem)
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: thread_id(chat),
turn_id: chat
.turn_lifecycle
.last_turn_id
.clone()
.unwrap_or_else(|| "turn-1".to_string()),
@@ -1227,6 +1097,7 @@ pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem)
pub(super) fn active_blob(chat: &ChatWidget) -> 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"
);
}
@@ -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());
}
@@ -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),
@@ -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
+111 -56
View File
@@ -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) {
+2 -2
View File
@@ -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]
@@ -99,10 +99,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));
@@ -116,9 +116,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));
@@ -178,9 +182,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));
@@ -192,7 +200,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]
@@ -229,7 +237,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]
@@ -248,7 +256,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,
@@ -268,7 +276,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) {
@@ -281,7 +289,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),
@@ -301,7 +309,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]
@@ -340,7 +348,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]
@@ -355,7 +363,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));
@@ -391,7 +399,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]
@@ -437,9 +445,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"]
@@ -474,7 +482,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]
@@ -499,7 +507,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]
@@ -746,7 +754,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();
@@ -1373,7 +1381,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");
@@ -1496,7 +1504,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 { .. });
}
@@ -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 =
@@ -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(),
+151
View File
@@ -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<Box<dyn HistoryCell>>,
/// 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<String>,
/// Copyable agent responses keyed by the number of visible user turns at
/// the time the response completed.
pub(super) agent_turn_markdowns: Vec<AgentTurnMarkdown>,
/// 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<String>,
/// 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<Box<dyn HistoryCell>>) -> 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);
}
}
@@ -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<String>,
pub(super) budget_limited_turn_ids: HashSet<String>,
pub(super) goal_status_active_turn_started_at: Option<Instant>,
}
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"));
}
}