mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
90c0bec50c
commit
789b7e39dc
+349
-940
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
¬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<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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user