返回 CodeWhale
app.rs
根目录 / crates / tui / src / tui / app.rs
1 //! Application state for the `DeepSeek` TUI.
2
3 use std::borrow::Cow;
4 use std::cell::RefCell;
5 use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
6 use std::path::{Path, PathBuf};
7 use std::sync::Arc;
8 use std::time::{Duration, Instant};
9
10 use chrono::{DateTime, Utc};
11 use ratatui::layout::Rect;
12 use ratatui::style::Color;
13 use serde_json::Value;
14
15 use codewhale_config::{AppMode, ProviderChain, route::RouteLimits};
16 use codewhale_core::ContextReference;
17 use codewhale_execpolicy::ApprovalMode;
18
19 use crate::artifacts::ArtifactRecord;
20 use crate::client::{CacheWarmupKey, PromptInspection};
21 use crate::compaction::CompactionConfig;
22 use crate::config::{
23 ApprovalPolicyControl, Config, DEFAULT_TEXT_MODEL, ProviderKind, has_api_key, has_api_key_for,
24 };
25 use crate::core::authority::{ModeSessionPrefs, base_policy_for_mode};
26 use crate::core::events::TurnRoute;
27 use crate::hooks::{HookContext, HookEvent, HookExecutor};
28 use crate::pricing::{CostCurrency, CostEstimate};
29 use crate::reasoning_preference::{EffectiveReasoningEffort, ReasoningEffort};
30 use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
31 use crate::settings::{InlineDiffMode, Settings};
32 use crate::tools::plan::{PlanState, SharedPlanState, new_shared_plan_state};
33 use crate::tools::shell::new_shared_shell_manager;
34 use crate::tools::spec::RuntimeToolServices;
35 use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult};
36 use crate::tools::todo::{SharedTodoList, TodoList, new_shared_todo_list};
37 use crate::tui::active_cell::ActiveCell;
38 use crate::tui::clipboard::{ClipboardContent, ClipboardHandler};
39 use crate::tui::history::{
40 HistoryCell, ThinkingFold, TranscriptActionOwner, TranscriptRenderOptions,
41 };
42 use crate::tui::hotbar::HotbarActionRegistry;
43 use crate::tui::motion::MotionPolicy;
44 use crate::tui::paste_burst::{FlushResult, PasteBurst};
45 use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll};
46 use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection};
47 use crate::tui::shell_key_routing::Focus;
48 use crate::tui::streaming::StreamingState;
49 use crate::tui::transcript::TranscriptViewCache;
50 use crate::tui::views::ViewStack;
51 use codewhale_localization::{Locale, MessageId, resolve_locale, tr};
52 use codewhale_models::{ContentBlock, Message, SystemPrompt, Tool, Usage};
53 use codewhale_palette::{self as palette, UiTheme};
54
55 mod composer;
56 mod init;
57 mod status;
58 mod types;
59
60 pub use composer::ComposerHistorySearch;
61 pub(crate) use composer::{InputHistoryDraft, char_count};
62 #[cfg(test)]
63 pub(crate) use composer::{
64 MAX_SUBMITTED_INPUT_CHARS, next_grapheme_boundary, prev_grapheme_boundary,
65 };
66 pub(crate) use status::StatusToastKind;
67 pub use status::{StatusToast, StatusToastLevel};
68
69 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70 pub(crate) enum RedactionGateNotice {
71 EnterGuidance,
72 WriteFailure,
73 }
74 pub use types::{
75 AppAction, AppModeUi, AutomationAction, ComposerDensity, ComposerSubmitAction,
76 ComposerSubmitChord, InflightSteer, InitialInput, McpUiAction, QueuedMessage, ScreenMode,
77 SettingSelection, ShellJobAction, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
78 ToolCollapseMode, ToolDetailRecord, TranscriptSpacing, TuiOptions, VimMode,
79 };
80 pub(crate) use types::{
81 CacheReplayTarget, GoalControlIntent, PendingGoalControl, UnansweredSubmission,
82 WORKFLOW_DRAFT_INSTRUCTION_PREFIX,
83 };
84
85 // === Types ===
86
87 /// One login owns one mailbox. A cancelled task can only write its abandoned
88 /// mailbox, so a late result cannot complete or clear a later login.
89 pub(crate) struct PendingMcpLogin {
90 pub server: String,
91 pub cancel: tokio_util::sync::CancellationToken,
92 pub progress: std::sync::Arc<std::sync::Mutex<Option<McpLoginProgress>>>,
93 }
94
95 pub(crate) enum McpLoginProgress {
96 AuthorizationUrl(String),
97 Finished(Result<(), String>),
98 }
99
100 /// One `/mcp retry <name>` in flight. The retry runs as an engine op from a
101 /// background task, so a running turn queues it in the engine mailbox instead
102 /// of parking the UI loop (#6159) or asking the person to press it again; the
103 /// outcome lands in `result` and `poll_mcp_retries` reports it.
104 pub(crate) struct PendingMcpRetry {
105 pub server: String,
106 /// A turn owned the engine when the retry was requested, so it waits for
107 /// that turn to finish before it connects.
108 pub queued: bool,
109 pub result: std::sync::Arc<
110 std::sync::Mutex<Option<Result<crate::core::ops::McpManagerUpdate, String>>>,
111 >,
112 }
113
114 impl Drop for PendingMcpLogin {
115 fn drop(&mut self) {
116 self.cancel.cancel();
117 }
118 }
119
120 /// Lifecycle identity retained until the matching `TurnComplete` arrives.
121 ///
122 /// This survives local cancellation clearing the visible runtime status, so
123 /// observer records still carry a stable id, start time, and effective route.
124 #[derive(Debug, Clone, PartialEq, Eq)]
125 pub struct ActiveTurnMetadata {
126 pub turn_id: String,
127 pub created_at: DateTime<Utc>,
128 pub route: Option<TurnRoute>,
129 /// Auto decision metadata captured with this exact authoritative route.
130 pub auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
131 /// Non-secret proof of the exact endpoint + credential this turn launched
132 /// against, adopted at `TurnStarted` from the engine's route receipt — not
133 /// re-resolved from mutable config. Only populated for routes that can
134 /// produce a follow-up prompt suggestion; see
135 /// [`crate::tui::prompt_suggestion::capture_route_authority`].
136 pub suggestion_authority: Option<crate::tui::prompt_suggestion::SuggestionRouteAuthority>,
137 }
138
139 /// Identity of the compaction pass currently rewriting session context.
140 ///
141 /// The event id prevents a delayed terminal event from clearing a newer pass,
142 /// while `auto` selects the truthful live label in the phase strip.
143 #[derive(Debug, Clone, PartialEq, Eq)]
144 pub(crate) struct ActiveCompaction {
145 pub(crate) id: String,
146 pub(crate) auto: bool,
147 }
148
149 /// Per-message context estimates used by the render-time context meter.
150 /// Messages are append-only in the steady state; only the streaming tail is
151 /// mutable, so the tail is refreshed while older entries remain cached.
152 #[derive(Debug, Default)]
153 pub(crate) struct ContextTokenCache {
154 pub(crate) message_tokens: Vec<usize>,
155 }
156
157 #[derive(Debug, Clone, PartialEq, Eq)]
158 struct CompletedAssistantOutputReceipt {
159 history_index: usize,
160 text: String,
161 }
162
163 impl ContextTokenCache {
164 pub(crate) fn clear(&mut self) {
165 self.message_tokens.clear();
166 }
167 }
168
169 /// State machine for onboarding new users: one decision per screen, and
170 /// only the decisions this install genuinely needs (#3938).
171 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
172 pub enum OnboardingState {
173 Welcome,
174 /// Pick the UI locale — shown only when it cannot be confidently
175 /// inferred from settings or `LC_ALL` / `LANG` (#566). Explicit picks
176 /// land in the persisted settings.toml via `Settings::set("locale", …)`.
177 Language,
178 /// Choose a provider/model route — shown only when no usable local,
179 /// authenticated, or BYOK route is configured. The canonical provider
180 /// picker modal carries the choice itself.
181 Provider,
182 /// Trust the workspace — shown only when a trust decision is required.
183 TrustDirectory,
184 /// "You're ready." Enter opens the real composer pre-seeded with a
185 /// first task for this folder. Never an educational surface.
186 Ready,
187 None,
188 }
189
190 /// Pick the session's primary skills dir. A workspace directory is chosen only
191 /// when the workspace-trust gate admits it; an untrusted repository falls back
192 /// to the global dir so its skills neither load nor become the install target.
193 pub(crate) fn resolve_skills_dir(
194 workspace: &Path,
195 global_skills_dir: &Path,
196 config: &Config,
197 ) -> PathBuf {
198 let admitted =
199 |dir: &Path| crate::skills::skills_dir_allowed_by_workspace_trust(workspace, dir);
200 if config.skills_config().scan_codewhale_only() {
201 if config.skills_dir.is_some() {
202 return global_skills_dir.to_path_buf();
203 }
204 if let Some(codewhale_skills_dir) = crate::skills::codewhale_workspace_skills_dir(workspace)
205 && admitted(&codewhale_skills_dir)
206 {
207 return codewhale_skills_dir;
208 }
209 return global_skills_dir.to_path_buf();
210 }
211
212 for local_skills_dir in [
213 workspace.join(".codewhale/skills"),
214 workspace.join(".agents/skills"),
215 workspace.join(".claude/skills"),
216 workspace.join(".opencode/skills"),
217 workspace.join(".cursor/skills"),
218 ] {
219 if local_skills_dir.exists() && admitted(&local_skills_dir) {
220 return local_skills_dir;
221 }
222 }
223
224 let flat = workspace.join("skills");
225 if config.skills_config().flat_workspace_root() && flat.exists() && admitted(&flat) {
226 return flat;
227 }
228 if global_skills_dir.exists() {
229 return global_skills_dir.to_path_buf();
230 }
231 if config.skills_dir.is_none()
232 && let Some(global_agents) = crate::skills::agents_global_skills_dir()
233 && global_agents.exists()
234 {
235 return global_agents;
236 }
237
238 global_skills_dir.to_path_buf()
239 }
240
241 pub(crate) fn looks_like_slash_command_input(input: &str) -> bool {
242 let trimmed = input.trim_start();
243 // `$skillname` at the start of input is treated like a slash command so the
244 // skill-completion menu appears.
245 let Some(rest) = trimmed
246 .strip_prefix('/')
247 .or_else(|| trimmed.strip_prefix('$'))
248 else {
249 return false;
250 };
251 if rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
252 return false;
253 }
254 let Some(command) = rest.split_whitespace().next() else {
255 return rest.is_empty();
256 };
257
258 !command.contains('/')
259 }
260
261 pub(crate) fn shell_command_from_bang_input(input: &str) -> Result<Option<&str>, &'static str> {
262 let Some(rest) = input.trim_start().strip_prefix('!') else {
263 return Ok(None);
264 };
265 let command = rest.trim();
266 if command.is_empty() {
267 return Err("Usage: ! <shell command>");
268 }
269
270 Ok(Some(command))
271 }
272
273 pub(crate) fn is_stop_word(input: &str, stop_words: &[String]) -> Option<String> {
274 let trimmed = input.trim();
275 let after_prefix = trimmed
276 .strip_prefix('+')
277 .or_else(|| trimmed.strip_prefix('!'))
278 .map_or(trimmed, str::trim_start);
279 let word = after_prefix.trim_end_matches(|c: char| c.is_ascii_punctuation());
280 if word.is_empty() || word.chars().any(char::is_whitespace) {
281 return None;
282 }
283 let lower = word.to_ascii_lowercase();
284 stop_words
285 .iter()
286 .find(|stop_word| stop_word.to_ascii_lowercase() == lower)
287 .cloned()
288 }
289
290 fn initial_onboarding_state(
291 skip_onboarding: bool,
292 was_onboarded: bool,
293 _needs_language: bool,
294 needs_api_key: bool,
295 needs_workspace_trust: bool,
296 ) -> OnboardingState {
297 if skip_onboarding || (was_onboarded && !needs_api_key && !needs_workspace_trust) {
298 return OnboardingState::None;
299 }
300
301 if needs_api_key {
302 // Nothing can answer until a model is connected, so the first screen
303 // is the one that connects it (#6566). A returning user keeps the
304 // configured route focused; a new user sees the provider list. It is
305 // one screen, not the old five-gate wizard, and Esc leaves it for the
306 // composer.
307 OnboardingState::Provider
308 } else if was_onboarded && needs_workspace_trust {
309 OnboardingState::TrustDirectory
310 } else {
311 // A new user who already has a key starts at the composer. Language
312 // and trust stay in /setup.
313 OnboardingState::None
314 }
315 }
316
317 fn onboarding_is_workspace_trust_gate(
318 skip_onboarding: bool,
319 was_onboarded: bool,
320 needs_api_key: bool,
321 needs_workspace_trust: bool,
322 ) -> bool {
323 !skip_onboarding && was_onboarded && !needs_api_key && needs_workspace_trust
324 }
325
326 /// Resolve the launch onboarding state and the missing-key-recovery flag in one
327 /// place. When the active xAI OAuth credential is missing (`xai_oauth_needs_reauth`),
328 /// the user already chose xAI and only needs to re-authenticate it — so the
329 /// generic provider picker must NOT reopen (returns `OnboardingState::None` and
330 /// `missing_key_recovery = false`); the caller surfaces a re-auth message (#5032).
331 fn launch_onboarding_decision(
332 skip_onboarding: bool,
333 was_onboarded: bool,
334 needs_language: bool,
335 needs_api_key: bool,
336 needs_workspace_trust: bool,
337 xai_oauth_needs_reauth: bool,
338 ) -> (OnboardingState, bool) {
339 let onboarding = if xai_oauth_needs_reauth {
340 OnboardingState::None
341 } else {
342 initial_onboarding_state(
343 skip_onboarding,
344 was_onboarded,
345 needs_language,
346 needs_api_key,
347 needs_workspace_trust,
348 )
349 };
350 // Both a new user and a returning one reach the picker directly, and Esc
351 // returns to the composer. An explicitly configured route is focused even
352 // on first run (see `onboarding_recovers_configured_route`).
353 let missing_key_recovery = !skip_onboarding && needs_api_key && !xai_oauth_needs_reauth;
354 (onboarding, missing_key_recovery)
355 }
356
357 /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263).
358 #[derive(Debug, Clone)]
359 pub struct TurnCacheRecord {
360 /// API provider used for the turn. This is recorded so cache misses can be
361 /// correlated with provider/model route changes.
362 pub provider: Option<ProviderKind>,
363 /// Exact non-secret configured route key. This distinguishes named custom
364 /// providers which all share [`ProviderKind::Custom`].
365 pub provider_identity: Option<String>,
366 /// Concrete model used for the turn. For auto-model turns this is the
367 /// routed model, not the literal `auto` setting.
368 pub model: Option<String>,
369 /// Whether the route came from the auto-model selector.
370 pub auto_model: bool,
371 /// Provider-reported total input tokens for the turn (cache-hit +
372 /// cache-miss + uncategorized). Useful for sanity-checking that hits +
373 /// misses sum back to roughly the prompt size.
374 pub input_tokens: u32,
375 /// Provider-reported output tokens.
376 pub output_tokens: u32,
377 /// `prompt_cache_hit_tokens` from DeepSeek's usage payload. `None` when
378 /// the model in use does not report cache telemetry (see
379 /// `Capabilities::cache_telemetry_supported`).
380 pub cache_hit_tokens: Option<u32>,
381 /// `prompt_cache_miss_tokens`. `None` when the provider did not report it
382 /// — in that case the `/cache` formatter infers the miss as
383 /// `input_tokens − cache_hit_tokens`.
384 pub cache_miss_tokens: Option<u32>,
385 /// Cache-creation tokens (`cache_creation_input_tokens` on Anthropic-style
386 /// payloads). Billed at a premium where the provider publishes one, so
387 /// they are recorded as their own class rather than folded into misses.
388 pub cache_write_tokens: Option<u32>,
389 /// Reasoning tokens the provider reported. **Informational only**: every
390 /// provider counts these inside `output_tokens`, so they are never added
391 /// to billable output.
392 pub reasoning_tokens: Option<u32>,
393 /// The turn's cost with its provenance and per-class completeness, taken
394 /// from the same call that fed the session total. `None` for records made
395 /// without route provenance (legacy rows, synthetic test rows).
396 pub cost_audit: Option<crate::pricing::TurnCostAudit>,
397 /// Approximate tokens spent re-sending prior `reasoning_content` on
398 /// V4-thinking tool-calling turns (chars/3 heuristic). Helps separate
399 /// cache misses caused by reasoning-replay churn from misses caused by
400 /// real prefix instability.
401 pub reasoning_replay_tokens: Option<u32>,
402 /// Local timestamp the turn telemetry was recorded.
403 pub recorded_at: Instant,
404 }
405
406 /// Browsing context captured when the `/model` picker is dismissed (#4109).
407 /// Plain data so `App` does not depend on the picker's internal view enum.
408 #[derive(Debug, Clone, PartialEq, Eq)]
409 pub struct ModelPickerMemory {
410 /// True when the user left the picker in the full-catalog view
411 /// (`A` toggle), false for the configured-only default view.
412 ///
413 /// Kept for backward compatibility with older dismiss events; prefer
414 /// [`Self::view`] when present (#4115).
415 pub catalog_view: bool,
416 /// Named catalog view left open (`configured` / `catalog` / `recent` /
417 /// `coding` / `cheap` / `long_context`). When `None`, [`Self::catalog_view`]
418 /// is the fallback.
419 pub view: Option<String>,
420 /// Model row id highlighted at dismissal, if it was a real row.
421 pub selected_row_id: Option<String>,
422 }
423
424 /// Browsing context captured when the `/provider` picker is dismissed.
425 /// Mirrors [`ModelPickerMemory`] so reopen restores view + highlight.
426 #[derive(Debug, Clone, PartialEq, Eq)]
427 pub struct ProviderPickerMemory {
428 /// True when the user left the picker in the full-catalog view
429 /// (`A` toggle), false for the configured-only default view.
430 pub catalog_view: bool,
431 /// Provider id highlighted at dismissal, if it was a real row.
432 pub selected_provider_id: Option<String>,
433 }
434
435 /// Bounded status vocabulary for the per-agent current-activity projection.
436 ///
437 /// This is presentation state derived from structured worker/mailbox events;
438 /// renderers map these variants to labels but never infer them from strings.
439 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
440 pub enum AgentCurrentActivityStatus {
441 Queued,
442 Starting,
443 Running,
444 ModelWait,
445 RunningTool,
446 Waiting,
447 /// Settled because the parent's turn ended before this child did, not
448 /// because it asked anyone anything (#5906).
449 ///
450 /// The runtime parks such a child with a `needs_input` note that reads
451 /// like a question, so every surface used to render it as
452 /// `waiting for input` — indistinguishable from a child a user can
453 /// actually answer. It is its own state here because the recovery is
454 /// different: nobody will answer it, and it is continued through
455 /// `resume_from` (a *new* agent) or dismissed with `cancel`.
456 Parked,
457 Done,
458 Failed,
459 Canceled,
460 Interrupted,
461 }
462
463 impl From<AgentWorkerStatus> for AgentCurrentActivityStatus {
464 /// Never yields [`Self::Parked`]: the worker status vocabulary cannot
465 /// express it (a parked child reports `WaitingForUser` /`Interrupted`
466 /// like any other settled one). Parked is derived from the checkpoint
467 /// flag by `crate::tui::subagent_routing::subagent_is_parked` and layered
468 /// over this mapping there — the one place that distinction is made.
469 fn from(status: AgentWorkerStatus) -> Self {
470 match status {
471 AgentWorkerStatus::Queued => Self::Queued,
472 AgentWorkerStatus::Starting => Self::Starting,
473 AgentWorkerStatus::Running => Self::Running,
474 AgentWorkerStatus::WaitingForUser => Self::Waiting,
475 AgentWorkerStatus::ModelWait => Self::ModelWait,
476 AgentWorkerStatus::RunningTool => Self::RunningTool,
477 AgentWorkerStatus::Completed => Self::Done,
478 AgentWorkerStatus::Failed => Self::Failed,
479 AgentWorkerStatus::Cancelled => Self::Canceled,
480 AgentWorkerStatus::Interrupted => Self::Interrupted,
481 }
482 }
483 }
484
485 #[derive(Debug, Clone, PartialEq, Eq)]
486 pub struct AgentCurrentActivity {
487 pub status: AgentCurrentActivityStatus,
488 /// Safe bounded context, never a raw child transcript or tool result.
489 pub detail: Option<String>,
490 /// Safe display name for the one tool currently executing.
491 pub current_tool: Option<String>,
492 pub step: Option<u32>,
493 }
494
495 impl AgentCurrentActivity {
496 #[must_use]
497 pub fn bounded(
498 status: AgentCurrentActivityStatus,
499 detail: Option<String>,
500 current_tool: Option<String>,
501 step: Option<u32>,
502 ) -> Self {
503 fn bounded_nonempty(value: Option<String>) -> Option<String> {
504 value
505 .map(|value| bound_agent_activity_text(&value))
506 .filter(|value| !value.trim().is_empty())
507 }
508
509 Self {
510 status,
511 detail: bounded_nonempty(detail),
512 current_tool: bounded_nonempty(current_tool),
513 step,
514 }
515 }
516 }
517
518 /// Convert untrusted child-agent text into a compact UI-safe projection.
519 /// Full transcript artifacts remain the source of truth; only summaries that
520 /// can enter the parent transcript/sidebar pass through this seam.
521 pub(crate) fn bound_agent_activity_text(value: &str) -> String {
522 let mut visible = String::with_capacity(value.len());
523 crate::tui::osc8::strip_ansi_into(value, &mut visible);
524 let redacted = codewhale_config::persistence::redact_secrets(&visible);
525 crate::tui::history::summarize_tool_output(&redacted)
526 }
527
528 /// One bounded, structured tool outcome for the Agent Details projection.
529 ///
530 /// This is populated only from `ToolCallCompleted` mailbox envelopes. It is
531 /// deliberately not inferred from free-form progress text.
532 #[derive(Debug, Clone, PartialEq, Eq)]
533 pub struct AgentRecentAction {
534 pub tool: String,
535 pub step: u32,
536 pub ok: bool,
537 }
538
539 impl AgentRecentAction {
540 #[must_use]
541 pub fn bounded(tool: &str, step: u32, ok: bool) -> Self {
542 Self {
543 tool: bound_agent_activity_text(tool),
544 step,
545 ok,
546 }
547 }
548 }
549
550 pub(crate) const MAX_AGENT_RECENT_ACTIONS: usize = 3;
551
552 #[derive(Debug, Clone, Default, PartialEq, Eq)]
553 pub struct AgentProgressMeta {
554 pub parent_run_id: Option<String>,
555 pub spawn_depth: u32,
556 /// Structured, bounded answer to "what is this agent doing now?".
557 pub current_activity: Option<AgentCurrentActivity>,
558 /// Last tool observed running for this child. Cleared by the matching
559 /// completion envelope so Work never presents a settled tool as live.
560 pub current_tool: Option<String>,
561 /// Successful file mutations observed for this child in this session.
562 pub files_touched: u32,
563 /// At most three tool outcomes observed through structured lifecycle
564 /// envelopes, oldest to newest.
565 pub recent_actions: VecDeque<AgentRecentAction>,
566 /// Effective route facts observed from the child's installed spawn route
567 /// or a later provider usage envelope. The launch event carries the exact
568 /// model frozen into the child runtime; usage may confirm it while also
569 /// supplying provider identity.
570 pub resolved_provider: Option<String>,
571 pub resolved_model: Option<String>,
572 /// Tokens this child has *used* (input + output), accumulated across its
573 /// own usage envelopes — the same total the worker budget tracks.
574 /// `None` until the provider actually reports usage: a sub-agent whose
575 /// spend is unknown renders no token figure at all rather than a
576 /// fabricated `0`.
577 pub received_tokens: Option<u64>,
578 /// Unsettled items on this child's own To-do list, from the latest
579 /// `WorkState` envelope. `None` until a real list is published — the
580 /// strip never invents a `0 left` chip for agents with no checklist.
581 pub todos_remaining: Option<u32>,
582 /// The engine's name for this agent from its spawn or completion event
583 /// (`subagent_display_name`), used until a manager snapshot arrives.
584 pub display_name: Option<String>,
585 /// When the TUI last received any mailbox envelope from this child. The
586 /// manager's `idle_ms` is only as fresh as the last `AgentList` snapshot,
587 /// and ordinary progress does not refresh that snapshot, so the quiet
588 /// readout caps the engine's clock with this one.
589 pub last_progress_at: Option<Instant>,
590 }
591
592 /// Per-turn LSP repair-loop summary for the Turn Inspector (#4107).
593 /// Observable state only — no raw diagnostic text or prompt internals.
594 #[derive(Debug, Clone, PartialEq, Eq)]
595 pub struct LspRepairState {
596 pub diagnostics_found: usize,
597 pub files_touched: usize,
598 pub injected: bool,
599 pub repair_attempted: bool,
600 /// "resolved" | "still_failing" | "unknown" | "unavailable"
601 pub latest: &'static str,
602 }
603
604 impl Default for LspRepairState {
605 fn default() -> Self {
606 Self {
607 diagnostics_found: 0,
608 files_touched: 0,
609 injected: false,
610 repair_attempted: false,
611 latest: "unavailable",
612 }
613 }
614 }
615
616 /// One recent session for the startup card's recent-work list (PRD 4.1).
617 /// Loaded once with the launch state — never on the render path — and
618 /// refreshed whenever the card is restored after a picker closes.
619 #[derive(Debug, Clone, PartialEq, Eq)]
620 pub struct LaunchRecentSession {
621 pub id: String,
622 pub title: String,
623 pub updated_at: DateTime<Utc>,
624 pub message_count: usize,
625 }
626
627 /// Identity of one interactive row on the startup card. The card's rows are
628 /// a single ordered list — the prominent new-session entry first, then
629 /// recent work, then the see-all overflow — so keyboard, mouse, and paint
630 /// share one indexing through
631 /// [`crate::tui::underwater::launch_card_rows`].
632 #[derive(Debug, Clone, PartialEq, Eq)]
633 pub enum LaunchRowId {
634 NewSession,
635 ReturnToSession,
636 Recent(String),
637 SeeAll,
638 /// Open the MCP manager from the status summary, including healthy servers.
639 McpManager,
640 /// The MCP problems row: Enter/click types the remedy command into the
641 /// composer (`/mcp login <name>` or `/mcp`) instead of making the user
642 /// retype what the card printed (#6085).
643 McpRemedy,
644 }
645
646 /// How many recent sessions the startup card lists inline before the
647 /// see-all overflow opens the full picker.
648 pub(crate) const LAUNCH_RECENT_INLINE_LIMIT: usize = 5;
649
650 /// Pre-session launch menu state for the underwater shell.
651 ///
652 /// This is deliberately separate from onboarding and from the post-launch
653 /// empty session. It selects a fresh session or recent work before the
654 /// transcript and composer become active.
655 #[derive(Debug, Clone, PartialEq, Eq)]
656 pub struct LaunchState {
657 pub visible: bool,
658 /// Home temporarily covers the current conversation; it does not own a session.
659 pub return_to_session: bool,
660 pub status: Option<String>,
661 /// Canonical workspace this launch state is scoped to. Recent work is
662 /// the workspace's own sessions (archived and empty auto-created ones
663 /// excluded, like the resume picker); the row hitboxes below are
664 /// refreshed with it.
665 pub workspace: PathBuf,
666 /// Recent workspace sessions, most recent first, capped at
667 /// [`LAUNCH_RECENT_INLINE_LIMIT`].
668 pub recent: Vec<LaunchRecentSession>,
669 /// All workspace sessions behind the inline list; when this exceeds
670 /// `recent.len()` the card paints the see-all overflow row.
671 pub total_workspace_sessions: usize,
672 /// Whether this workspace has any sessions at all — including the
673 /// empty auto-created shells `recent` deliberately drops. The card
674 /// must not say "no recent sessions yet" while `/resume` lists them;
675 /// when they exist it shows the see-all row instead of the lie.
676 pub has_scoped_sessions: bool,
677 /// Whether launch keys type into the pre-session composer. The composer
678 /// is the launch screen's one focus owner, so this is `true` from first
679 /// paint. The composer itself is the session `App`'s own
680 /// `ComposerState` — this flag only decides where keystrokes go.
681 pub composer_focus: bool,
682 /// Clickable rects for the card's rows from the most recent launch
683 /// render, in the same order as
684 /// [`crate::tui::underwater::launch_card_rows`].
685 pub row_hitboxes: Vec<(LaunchRowId, Rect)>,
686 /// Card row under the pointer, if any (index into `row_hitboxes`).
687 /// Painted with the shared selected-row treatment so every clickable
688 /// element responds visibly on hover.
689 pub hovered_row: Option<usize>,
690 /// The launch card's highlighted row (index into the rows the card
691 /// paints). `None` until the user arrows onto the list: nothing is
692 /// pre-selected, so a reflexive Enter at launch does nothing rather
693 /// than starting or resuming a session by accident (founder live-test,
694 /// 2026-09-02). Esc clears it again.
695 pub menu_selected: Option<usize>,
696 /// Ambient-clock millisecond reading when the card began dissolving, if
697 /// it has. The first keystroke or a launched command dissolves the card
698 /// (founder decision, 2026-09-02).
699 pub dissolve_started_ms: Option<u128>,
700 /// One bounded reveal of the canonical mark, anchored at first paint.
701 /// Kept when the launcher is revisited so it never replays on navigation.
702 pub mark_reveal_started_at: Option<Instant>,
703 /// Claude Code config was detected on this host (probed once at
704 /// construction); drives the launch card's migration notice line.
705 pub claude_code_detected: bool,
706 }
707
708 /// The launch card's dissolve motion budget. One bounded motion; reduced
709 /// motion dissolves instantly (same drawing at its endpoint).
710 pub(crate) const LAUNCH_CARD_DISSOLVE_MS: u128 = 240;
711
712 /// Load the startup card's recent-work list: the workspace's own sessions,
713 /// most recent first (`list_sessions` already sorts that way), skipping
714 /// archived sessions and — unlike the resume picker, which lists them —
715 /// empty auto-created shells. Returns the inline-capped list, the total
716 /// behind it for the see-all overflow, and whether any scoped sessions
717 /// exist at all so the card never claims "no recent sessions" while
718 /// `/resume` has some.
719 fn load_launch_recent(workspace: &std::path::Path) -> (Vec<LaunchRecentSession>, usize, bool) {
720 let sessions = crate::session_manager::SessionManager::default_location()
721 .and_then(|manager| manager.list_sessions())
722 .unwrap_or_default();
723 let any_scoped = sessions.iter().any(|session| {
724 !session.archived
725 && crate::session_manager::workspace_scope_matches(&session.workspace, workspace)
726 });
727 let mut scoped: Vec<LaunchRecentSession> = sessions
728 .into_iter()
729 .filter(|session| {
730 !session.archived
731 && !crate::session_manager::is_empty_auto_created_session(session)
732 && crate::session_manager::workspace_scope_matches(&session.workspace, workspace)
733 })
734 .map(|session| LaunchRecentSession {
735 id: session.id,
736 title: session.title,
737 updated_at: session.updated_at,
738 message_count: session.message_count,
739 })
740 .collect();
741 let total = scoped.len();
742 scoped.truncate(LAUNCH_RECENT_INLINE_LIMIT);
743 (scoped, total, any_scoped)
744 }
745
746 impl LaunchState {
747 #[must_use]
748 pub fn new(visible: bool, workspace: &std::path::Path) -> Self {
749 let (recent, total_workspace_sessions, has_scoped_sessions) = load_launch_recent(workspace);
750 // The migration notice answers a question you have exactly once:
751 // "I have Claude Code, what comes over?". It used to key on
752 // `~/.claude/projects` alone, so anyone who keeps Claude Code
753 // installed saw it on every single launch forever. It now retires as
754 // soon as `/import-claude` has been run — that command always writes
755 // its report, so the report is the durable receipt that the question
756 // has been answered. Two stats at construction, never on the render
757 // path.
758 let has_claude_code = std::env::var_os("HOME")
759 .as_ref()
760 .map(|home| {
761 std::path::Path::new(home)
762 .join(".claude")
763 .join("projects")
764 .is_dir()
765 })
766 .unwrap_or(false);
767 let import_already_reviewed = codewhale_config::codewhale_home()
768 .map(|home| {
769 home.join("imports")
770 .join("claude-import-report.md")
771 .exists()
772 })
773 .unwrap_or(false);
774 let claude_code_detected = has_claude_code && !import_already_reviewed;
775 Self {
776 visible,
777 return_to_session: false,
778 status: None,
779 workspace: workspace.to_path_buf(),
780 recent,
781 total_workspace_sessions,
782 has_scoped_sessions,
783 composer_focus: true,
784 row_hitboxes: Vec::new(),
785 hovered_row: None,
786 menu_selected: None,
787 dissolve_started_ms: None,
788 mark_reveal_started_at: None,
789 claude_code_detected,
790 }
791 }
792
793 /// Leave home without resetting the conversation, draft, or reveal clock.
794 pub fn dismiss(&mut self) {
795 self.visible = false;
796 self.return_to_session = false;
797 self.row_hitboxes.clear();
798 self.menu_selected = None;
799 self.hovered_row = None;
800 }
801
802 /// Re-read the recent-work list from disk (same filter as
803 /// construction). Called when the card is restored after a picker
804 /// closes so a session created or renamed behind the picker shows up.
805 pub fn refresh_recent(&mut self) {
806 let (recent, total, any_scoped) = load_launch_recent(&self.workspace.clone());
807 self.recent = recent;
808 self.total_workspace_sessions = total;
809 self.has_scoped_sessions = any_scoped;
810 }
811
812 /// Begin the card dissolve once (idempotent). The first keystroke or a
813 /// launched command dissolves the launch card.
814 pub fn dissolve_card(&mut self, now_ms: u128) {
815 if self.dissolve_started_ms.is_none() {
816 self.dissolve_started_ms = Some(now_ms);
817 }
818 }
819
820 /// Bring the card back after a launch flow (the sessions picker) is
821 /// left with Esc: every launch path has a way back to the card, so a
822 /// dismissed picker never strands the user on an empty stage. The list
823 /// comes back with nothing highlighted, and the recent-work list is
824 /// re-read so sessions created behind the picker show up.
825 pub fn restore_card(&mut self) {
826 self.dissolve_started_ms = None;
827 self.menu_selected = None;
828 self.hovered_row = None;
829 self.status = None;
830 self.refresh_recent();
831 }
832
833 /// True while the card is still painting — visible and not fully
834 /// dissolved. Hitboxes and clicks follow the paint, so a dissolved card
835 /// owns no rows.
836 #[must_use]
837 pub fn card_paintable(&self, now_ms: u128, motion_allowed: bool) -> bool {
838 self.visible && self.card_dissolve_progress(now_ms, motion_allowed) < 1.0
839 }
840
841 /// How far the card has dissolved, `[0.0 intact ..= 1.0 gone]`. Reduced
842 /// motion dissolves instantly: the same drawing at its endpoint.
843 #[must_use]
844 pub fn card_dissolve_progress(&self, now_ms: u128, motion_allowed: bool) -> f32 {
845 match self.dissolve_started_ms {
846 None => 0.0,
847 Some(_) if !motion_allowed => 1.0,
848 Some(started) => {
849 let elapsed = now_ms.saturating_sub(started);
850 (elapsed as f32 / LAUNCH_CARD_DISSOLVE_MS as f32).clamp(0.0, 1.0)
851 }
852 }
853 }
854 }
855
856 /// Cached @-mention completion results to avoid re-walking the filesystem when
857 /// the cursor moves inside the same mention token.
858 #[derive(Debug, Clone)]
859 pub struct MentionCompletionCache {
860 /// Workspace root used for this completion walk.
861 pub workspace: PathBuf,
862 /// Process cwd captured for cwd-relative completion entries.
863 pub cwd: Option<PathBuf>,
864 /// The partial text after `@` that triggered this completion.
865 pub partial: String,
866 /// Candidate limit used for this completion walk.
867 pub limit: usize,
868 /// Workspace depth limit used for this completion walk. Included so live
869 /// config changes invalidate cached popup results.
870 pub walk_depth: usize,
871 /// Completion behavior used for this walk. Included so live config changes
872 /// invalidate cached popup results.
873 pub behavior: String,
874 /// Whether symlink following was enabled for this completion walk.
875 /// Included so live config changes invalidate cached popup results.
876 pub follow_links: bool,
877 /// Cached completion entries.
878 pub entries: Vec<String>,
879 }
880
881 /// Composer input state — grouped fields for the text input area.
882 pub struct ComposerState {
883 /// Current composer text content.
884 pub input: String,
885 /// Cursor position within `input` (in characters).
886 pub cursor_position: usize,
887 /// Single-entry kill buffer for emacs-style `Ctrl+K` cut / `Ctrl+Y` yank.
888 pub kill_buffer: String,
889 pub paste_burst: PasteBurst,
890 /// When a large paste is consolidated at submit time, the file @mention
891 /// is stored here so it can be appended to the submitted text without
892 /// replacing the visible composer content (#3263).
893 pub(crate) pending_paste_reference: Option<String>,
894 /// When composer content is oversized, the full text is stored here
895 /// while `self.input` shows a truncated preview. At submit time the
896 /// full text is restored for model submission (#3263).
897 pub(crate) oversized_paste_full_text: Option<String>,
898 pub input_history: Vec<String>,
899 pub draft_history: VecDeque<String>,
900 pub clear_undo_buffer: Option<String>,
901 pub history_index: Option<usize>,
902 pub(crate) history_navigation_draft: Option<InputHistoryDraft>,
903 pub composer_history_search: Option<ComposerHistorySearch>,
904 pub selected_attachment_index: Option<usize>,
905 pub slash_menu_selected: usize,
906 pub slash_menu_hidden: bool,
907 pub mention_menu_selected: usize,
908 pub mention_menu_hidden: bool,
909 /// Cached @-mention completions to avoid re-walking the filesystem when
910 /// the cursor moves inside the same mention token.
911 pub mention_completion_cache: Option<MentionCompletionCache>,
912 /// Serialized background discovery and its bounded candidate cache. All
913 /// filesystem traversal for composer completions lives behind this owner.
914 pub(crate) mention_discovery: crate::tui::mention_completion::MentionDiscovery,
915 /// Launch directory captured once so rendering a completion popup never
916 /// needs to call `getcwd` on the UI thread.
917 pub(crate) mention_cwd: Option<PathBuf>,
918 /// Whether vim modal editing is enabled for this composer.
919 /// Sourced from `Settings::composer_vim_mode` at startup.
920 pub vim_enabled: bool,
921 /// Current vim editing mode. Only meaningful when `vim_enabled` is true.
922 pub vim_mode: VimMode,
923 /// Pending `d` prefix for the `dd` delete-line operator. Set when the
924 /// user presses `d` in Normal mode; cleared on the next key (either `d`
925 /// to complete `dd`, or any other key to cancel).
926 pub vim_pending_d: bool,
927 /// When set, the cursor is the active end of a text selection and
928 /// `selection_anchor` is the fixed end. Both are char-indexed.
929 /// `None` means no selection is active.
930 pub selection_anchor: Option<usize>,
931 /// The first character typed into this composer line was `/` (#5925).
932 ///
933 /// A line that began as a command stays a command until Enter: if the
934 /// leading `/` is gone at submit time and no edit removed it, bytes were
935 /// lost between the terminal and the composer, and the line must not be
936 /// re-interpreted as a prose prompt for the model. Composer edits
937 /// re-derive the claim through
938 /// [`ComposerState::resync_command_line_claim`]; `clear_input` drops it.
939 pub(crate) line_began_with_slash: bool,
940 /// Startup consumed bytes it could not replay, so the shell cannot prove
941 /// it saw the whole line (#5925). Set once from the startup input
942 /// receipt; cleared by the first submit it holds.
943 pub(crate) startup_input_unproven: bool,
944 }
945
946 impl Default for ComposerState {
947 fn default() -> Self {
948 Self {
949 input: String::new(),
950 cursor_position: 0,
951 kill_buffer: String::new(),
952 paste_burst: PasteBurst::default(),
953 pending_paste_reference: None,
954 oversized_paste_full_text: None,
955 input_history: Vec::new(),
956 draft_history: VecDeque::new(),
957 clear_undo_buffer: None,
958 history_index: None,
959 history_navigation_draft: None,
960 composer_history_search: None,
961 selected_attachment_index: None,
962 slash_menu_selected: 0,
963 slash_menu_hidden: false,
964 mention_menu_selected: 0,
965 mention_menu_hidden: false,
966 mention_completion_cache: None,
967 mention_discovery: crate::tui::mention_completion::MentionDiscovery::default(),
968 mention_cwd: std::env::current_dir().ok(),
969 vim_enabled: false,
970 vim_mode: VimMode::Normal,
971 vim_pending_d: false,
972 selection_anchor: None,
973 line_began_with_slash: false,
974 startup_input_unproven: false,
975 }
976 }
977 }
978
979 /// Viewport/scroll state — fields related to transcript scrolling and caching.
980 pub struct ViewportState {
981 /// Per-draw copy of the owning backend's negotiated capability facts.
982 /// Unavailable outside a backend draw; never redetect or infer depth.
983 pub(crate) ocean_caps: Option<codewhale_ratatui::Caps>,
984 /// Explicit semantic grounds projected by the current transcript painter.
985 /// Like hitboxes, cleared/rebuilt each frame, never persisted authority.
986 pub(crate) ocean_semantic_surfaces: Vec<Rect>,
987 pub transcript_scroll: TranscriptScroll,
988 pub pending_scroll_delta: i32,
989 /// Applied inside the next synchronized frame, including resize clears.
990 pub(crate) pending_terminal_size: Option<ratatui::layout::Size>,
991 pub mouse_scroll: MouseScrollState,
992 pub transcript_cache: TranscriptViewCache,
993 pub transcript_selection: TranscriptSelection,
994 pub selection_autoscroll: Option<SelectionAutoscroll>,
995 pub transcript_scrollbar_dragging: bool,
996 /// Copy transcript drag selections as Markdown source (see
997 /// `TuiConfig::selection_copy_markdown`). Resolved from config at startup;
998 /// defaults to on.
999 pub selection_copy_markdown: bool,
1000 pub last_transcript_area: Option<Rect>,
1001 pub last_composer_area: Option<Rect>,
1002 /// Selectable targets from the latest painted frame. Cleared before every
1003 /// render so resized or hidden controls can never swallow a click.
1004 pub interaction_targets: crate::tui::tideline::InteractionRegistry,
1005 /// Last left-click trace over the composer, for double/triple-click
1006 /// word/line selection (crossterm does not decode click counts).
1007 pub composer_click_trace: Option<crate::tui::mouse_ui::ComposerClickTrace>,
1008 /// Painted band occupied by the active approval or question sheet. Stored
1009 /// so wheel routing can prefer the prompt over side surfaces underneath it.
1010 pub last_prompt_area: Option<Rect>,
1011 /// The workbar's painted rows under the posture bar; a click there opens
1012 /// `/workflows`.
1013 pub last_workbar_area: Option<Rect>,
1014 /// Info-line segment rects (Tideline shell, spec §6), recorded at render so
1015 /// hover and — in a follow-up slice — click routing can hit-test the
1016 /// painted cells. Mirrors the workflow-panel cancel-area storage pattern.
1017 pub last_infoline_hitboxes: Vec<crate::tui::infoline::InfoLineHitbox>,
1018 /// Live plugin CTA row above the composer, plus review/dismiss hitboxes.
1019 pub last_plugin_cta_area: Option<Rect>,
1020 pub last_plugin_cta_review_area: Option<Rect>,
1021 pub last_plugin_cta_dismiss_area: Option<Rect>,
1022 pub last_transcript_top: usize,
1023 pub last_transcript_visible: usize,
1024 pub last_transcript_total: usize,
1025 pub last_transcript_padding_top: usize,
1026 pub jump_to_latest_button_area: Option<Rect>,
1027 /// Painted rect of the pinned user-prompt header above the transcript,
1028 /// when one is shown and mouse capture is on. A left click there jumps
1029 /// the viewport to the message named by `pinned_prompt_message`.
1030 pub pinned_prompt_area: Option<Rect>,
1031 /// Original history index of the user message the pinned header
1032 /// describes; the click target for `pinned_prompt_area`. Stored as a
1033 /// message identity, not a line offset, because line offsets are
1034 /// frame-bound and a rewrite between paint and click would otherwise
1035 /// land the jump on whatever now sits at the stale offset.
1036 pub pinned_prompt_message: Option<usize>,
1037 /// Inner content rect of the composer (excluding border/padding),
1038 /// stored at render time for mouse coordinate mapping.
1039 pub last_composer_content: Option<Rect>,
1040 /// Number of rendered text lines scrolled off the top of the composer,
1041 /// stored at render time for mouse coordinate mapping.
1042 pub last_composer_scroll_offset: usize,
1043 /// Vertical padding above the first text line in the composer,
1044 /// stored at render time for mouse coordinate mapping.
1045 pub last_composer_top_padding: usize,
1046 /// Slash-autocomplete rows painted inside the composer on the latest
1047 /// frame. Cleared and rewritten during `ComposerWidget::render` so a
1048 /// resized or closed menu cannot swallow a click. Index is the entry
1049 /// index into the visible slash menu (same as `slash_menu_selected`).
1050 pub last_slash_menu_hitboxes: RefCell<Vec<(usize, Rect)>>,
1051 }
1052
1053 impl Default for ViewportState {
1054 fn default() -> Self {
1055 Self {
1056 ocean_caps: None,
1057 ocean_semantic_surfaces: Vec::new(),
1058 transcript_scroll: TranscriptScroll::to_bottom(),
1059 pending_scroll_delta: 0,
1060 pending_terminal_size: None,
1061 mouse_scroll: MouseScrollState::new(),
1062 transcript_cache: TranscriptViewCache::new(),
1063 transcript_selection: TranscriptSelection::default(),
1064 selection_autoscroll: None,
1065 transcript_scrollbar_dragging: false,
1066 selection_copy_markdown: true,
1067 last_transcript_area: None,
1068 last_composer_area: None,
1069 interaction_targets: crate::tui::tideline::InteractionRegistry::default(),
1070 composer_click_trace: None,
1071 last_prompt_area: None,
1072 last_workbar_area: None,
1073 last_infoline_hitboxes: Vec::new(),
1074 last_plugin_cta_area: None,
1075 last_plugin_cta_review_area: None,
1076 last_plugin_cta_dismiss_area: None,
1077 last_transcript_top: 0,
1078 last_transcript_visible: 0,
1079 last_transcript_total: 0,
1080 last_transcript_padding_top: 0,
1081 jump_to_latest_button_area: None,
1082 pinned_prompt_area: None,
1083 pinned_prompt_message: None,
1084 last_composer_content: None,
1085 last_composer_scroll_offset: 0,
1086 last_composer_top_padding: 0,
1087 last_slash_menu_hitboxes: RefCell::new(Vec::new()),
1088 }
1089 }
1090 }
1091
1092 /// Host-side tracking state for the active thread goal. Mirrors the
1093 /// engine's authoritative `SharedGoalState` snapshot (`GoalUpdated`) so the
1094 /// sidebar, top bar, and system prompt all read one truth.
1095 #[derive(Debug, Clone, Default)]
1096 pub struct HostGoalState {
1097 pub objective: Option<String>,
1098 pub token_budget: Option<u32>,
1099 pub tokens_used: u64,
1100 pub time_used_seconds: u64,
1101 pub continuation_count: u32,
1102 /// Why an unfinished goal is paused. Kept separate from the four-state
1103 /// status so usage, budget, and run-limit stops stay distinguishable.
1104 pub pause_reason: Option<crate::tools::goal::GoalPauseReason>,
1105 pub started_at: Option<Instant>,
1106 /// When the goal reached a terminal status (Complete/Blocked).
1107 /// While `None`, elapsed time keeps growing; once set, the sidebar freezes
1108 /// the timer at `finished_at - started_at` so completed goals stop ticking.
1109 pub finished_at: Option<Instant>,
1110 /// Latest progress the model reported for the active goal. Runtime-only
1111 /// display state; never persisted and never treated as verified.
1112 pub progress: Option<crate::tools::goal::GoalProgressReport>,
1113 pub status: crate::tools::goal::GoalStatus,
1114 }
1115
1116 /// Session cost and token telemetry state.
1117 #[derive(Debug, Clone)]
1118 pub struct SessionState {
1119 pub session_cost: f64,
1120 pub session_cost_cny: f64,
1121 /// Priced estimate accumulated from the in-flight turn's per-step
1122 /// `TurnUsage` receipts, so the cost surfaces move while a long agentic
1123 /// turn is still running. Display-only: cleared at `TurnComplete`, which
1124 /// re-prices the whole turn's cumulative usage authoritatively into
1125 /// `session_cost`. Never persisted.
1126 pub pending_turn_cost: f64,
1127 pub pending_turn_cost_cny: f64,
1128 /// Display-only per-step token deltas for the active turn. These are
1129 /// cleared at `TurnComplete` before authoritative cumulative totals land.
1130 pub pending_turn_total_tokens: u32,
1131 pub pending_turn_input_tokens: u32,
1132 pub pending_turn_output_tokens: u32,
1133 pub pending_turn_cache_hit_tokens: u32,
1134 pub pending_turn_cache_miss_tokens: u32,
1135 pub pending_turn_cache_write_tokens: u32,
1136 pub subagent_cost: f64,
1137 pub subagent_cost_cny: f64,
1138 /// Redacted provider-response identities already accrued. The same
1139 /// fingerprints are persisted by the session and worker projections.
1140 pub subagent_usage_sources: HashSet<String>,
1141 pub missing_usage_sources:
1142 std::collections::BTreeMap<String, crate::cost_status::MissingUsageCoverage>,
1143 pub missing_usage_overflowed: bool,
1144 pub displayed_cost_high_water: f64,
1145 pub displayed_cost_high_water_cny: f64,
1146 pub last_prompt_tokens: Option<u32>,
1147 pub last_completion_tokens: Option<u32>,
1148 pub last_prompt_cache_hit_tokens: Option<u32>,
1149 pub last_prompt_cache_miss_tokens: Option<u32>,
1150 pub last_reasoning_replay_tokens: Option<u32>,
1151 pub total_tokens: u32,
1152 pub total_conversation_tokens: u32,
1153 /// Accumulated token breakdown for the session.
1154 pub total_input_tokens: u32,
1155 pub total_cache_hit_tokens: u32,
1156 pub total_cache_miss_tokens: u32,
1157 /// Cache-creation (cache-write) tokens across the session. Tracked as its
1158 /// own class because providers that publish a write premium bill it above
1159 /// the ordinary input rate, so folding it into misses understated spend.
1160 pub total_cache_write_tokens: u32,
1161 pub total_output_tokens: u32,
1162 /// Prompt-cache classes the session's sub-agents and other background
1163 /// routes reported, from their drained cost batches (#6565). The same
1164 /// per-runtime-session scope as the parent's totals above. `None` until a
1165 /// background route reports cache telemetry: absent, never 0%.
1166 pub subagent_cache_hit_tokens: Option<u64>,
1167 pub subagent_cache_miss_tokens: Option<u64>,
1168 pub subagent_cache_write_tokens: Option<u64>,
1169 /// Turns whose route was money-metered and produced an authoritative
1170 /// price. These are exactly the turns inside `session_cost`.
1171 pub cost_priced_turns: u32,
1172 /// Turns whose route was money-metered — or of unknown billing basis — but
1173 /// produced no authoritative price, so they are missing from `session_cost`
1174 /// entirely. `/cost` reports this instead of presenting the subtotal as a
1175 /// complete figure.
1176 pub cost_unpriced_turns: u32,
1177 /// CNY-specific coverage. Most providers publish USD only, so these cannot
1178 /// share the USD counters without falsely calling a mixed-route CNY subtotal
1179 /// complete.
1180 pub cost_cny_priced_turns: u32,
1181 pub cost_cny_unpriced_turns: u32,
1182 /// Stable reason labels for the unpriced turns, in sorted order.
1183 ///
1184 /// `String` rather than `&'static str` because this state round-trips
1185 /// through a saved session: a label read back from disk was written by some
1186 /// build's vocabulary, not necessarily this one's.
1187 pub cost_unpriced_reasons: BTreeSet<String>,
1188 pub cost_cny_unpriced_reasons: BTreeSet<String>,
1189 /// Token classes used on some route this session that carry no published
1190 /// price. Their turns fail closed rather than under-report.
1191 pub cost_unpriced_classes: BTreeSet<String>,
1192 /// Provenance labels of the pricing rows behind the priced turns
1193 /// (`models_dev_bundled`, `provider_live`, `provider_docs`, …).
1194 pub cost_pricing_provenances: BTreeSet<String>,
1195 /// Live-pricing downgrade receipts: a live catalog row that could not be
1196 /// verified for the endpoint that served a turn, so the bundled snapshot was
1197 /// used instead of claiming authoritative live provenance.
1198 pub cost_live_pricing_defects: BTreeSet<String>,
1199 /// Live-pricing defects for which no bundled row could produce a price.
1200 pub cost_live_pricing_unusable_defects: BTreeSet<String>,
1201 /// One redacted receipt per distinct audited route:
1202 /// provider, configured identity, wire model, billing surface, endpoint
1203 /// fingerprint, billing mode, currency. Never a URL, credential, or filesystem path.
1204 pub cost_route_receipts: BTreeSet<String>,
1205 /// True when the restored session has no coverage state at all.
1206 ///
1207 /// Sessions written before coverage was tracked deserialize their new fields
1208 /// from serde defaults, which look exactly like "0 priced, 0 unpriced" — i.e.
1209 /// a complete total covering nothing. That reading is false, so the load path
1210 /// marks the session explicitly unknown and `/cost` says so rather than
1211 /// presenting fabricated completeness, even for an all-zero record (#4318).
1212 pub cost_coverage_unknown_legacy: bool,
1213 pub turn_cache_history: VecDeque<TurnCacheRecord>,
1214 pub last_cache_inspection: Option<PromptInspection>,
1215 pub last_warmup_key: Option<CacheWarmupKey>,
1216 /// Tool catalog from the most recent model request.
1217 ///
1218 /// `/cache inspect` uses this to inspect the same tool schema bytes
1219 /// that were eligible for the provider's prefix cache.
1220 pub last_tool_catalog: Option<Vec<Tool>>,
1221 /// Exact tool field captured at the latest model request seam.
1222 pub last_tool_request_snapshot: Option<crate::tool_inspection::ToolInspectionSnapshot>,
1223 /// API base URL used by the most recent model request or cache warmup.
1224 pub last_base_url: Option<String>,
1225 }
1226
1227 /// Sidebar hover state for mouse tooltip support.
1228 #[derive(Debug, Clone, Default)]
1229 pub struct SidebarHoverState {
1230 /// Rendered sections with their areas and full-text lines.
1231 pub sections: Vec<SidebarHoverSection>,
1232 }
1233
1234 /// Per-row metadata for sidebar detail popovers.
1235 #[derive(Debug, Clone, PartialEq, Eq)]
1236 pub enum SidebarRowAction {
1237 Command(String),
1238 /// Put a destructive command in the composer instead of executing it.
1239 /// The user confirms with Enter or cancels by editing/clearing the draft.
1240 #[allow(dead_code)] // destructive confirm path; mouse_ui already matches it (TUI-DOG-008)
1241 PrefillCommand(String),
1242 /// Select the persistent Agents panel. This is deliberately a navigation
1243 /// action rather than a modal: the Subagents summary is a group door, so
1244 /// it should reveal the standing register instead of fabricating a detail
1245 /// page for the count itself.
1246 ShowSubagentsPanel,
1247 /// Open the child's bounded, safe status projection. Exact transcript
1248 /// evidence is a separate explicit action (#2889).
1249 OpenAgentDetail {
1250 agent_id: String,
1251 },
1252 /// Open the child's artifact-first exact transcript. This is separate
1253 /// from the safe default details projection (#2889).
1254 OpenAgentTranscript {
1255 agent_id: String,
1256 },
1257 CancelAgent {
1258 agent_id: String,
1259 },
1260 /// Open the Work Graph inspector in the shared pager. Any lifecycle stop
1261 /// action is carried into that inspector instead of consuming row width.
1262 InspectWork {
1263 title: String,
1264 body: String,
1265 stop_action: Option<Box<SidebarRowAction>>,
1266 },
1267 }
1268
1269 /// Per-row metadata for sidebar detail popovers.
1270 #[derive(Debug, Clone, PartialEq, Eq)]
1271 pub struct SidebarHoverRow {
1272 /// Absolute row position in the terminal.
1273 pub row_y: u16,
1274 /// Text shown in the compact sidebar row.
1275 pub display_text: String,
1276 /// Full untruncated text for the popover.
1277 pub full_text: String,
1278 /// Optional additional detail line.
1279 pub detail: Option<String>,
1280 /// Whether the compact row lost information.
1281 pub is_truncated: bool,
1282 /// Slash command to execute when this row is clicked (#3028).
1283 /// `shell_*` job ids route through `/jobs` (e.g. `/jobs cancel
1284 /// shell_abc123`); task-manager ids route through `/task` (e.g.
1285 /// `/task show task_abc123`).
1286 pub click_action: Option<SidebarRowAction>,
1287 /// Optional narrower stop target for rows that show an inline `[x]`.
1288 pub stop_action: Option<SidebarRowAction>,
1289 pub stop_zone_start_col: Option<u16>,
1290 pub stop_zone_end_col: Option<u16>,
1291 }
1292
1293 /// Per-section metadata for sidebar hover detection.
1294 #[derive(Debug, Clone)]
1295 pub struct SidebarHoverSection {
1296 /// Content area within the section (inside border + padding).
1297 pub content_area: Rect,
1298 /// Full original text for each content line rendered.
1299 pub lines: Vec<String>,
1300 /// Per-row metadata for rich hover popovers.
1301 pub rows: Vec<SidebarHoverRow>,
1302 }
1303
1304 impl Default for SessionState {
1305 fn default() -> Self {
1306 Self {
1307 session_cost: 0.0,
1308 session_cost_cny: 0.0,
1309 pending_turn_cost: 0.0,
1310 pending_turn_cost_cny: 0.0,
1311 pending_turn_total_tokens: 0,
1312 pending_turn_input_tokens: 0,
1313 pending_turn_output_tokens: 0,
1314 pending_turn_cache_hit_tokens: 0,
1315 pending_turn_cache_miss_tokens: 0,
1316 pending_turn_cache_write_tokens: 0,
1317 subagent_cost: 0.0,
1318 subagent_cost_cny: 0.0,
1319 subagent_usage_sources: HashSet::new(),
1320 missing_usage_sources: std::collections::BTreeMap::new(),
1321 missing_usage_overflowed: false,
1322 displayed_cost_high_water: 0.0,
1323 displayed_cost_high_water_cny: 0.0,
1324 last_prompt_tokens: None,
1325 last_completion_tokens: None,
1326 last_prompt_cache_hit_tokens: None,
1327 last_prompt_cache_miss_tokens: None,
1328 last_reasoning_replay_tokens: None,
1329 total_tokens: 0,
1330 total_conversation_tokens: 0,
1331 total_input_tokens: 0,
1332 total_cache_hit_tokens: 0,
1333 total_cache_miss_tokens: 0,
1334 total_cache_write_tokens: 0,
1335 total_output_tokens: 0,
1336 subagent_cache_hit_tokens: None,
1337 subagent_cache_miss_tokens: None,
1338 subagent_cache_write_tokens: None,
1339 cost_priced_turns: 0,
1340 cost_unpriced_turns: 0,
1341 cost_cny_priced_turns: 0,
1342 cost_cny_unpriced_turns: 0,
1343 cost_unpriced_reasons: BTreeSet::new(),
1344 cost_cny_unpriced_reasons: BTreeSet::new(),
1345 cost_unpriced_classes: BTreeSet::new(),
1346 cost_pricing_provenances: BTreeSet::new(),
1347 cost_live_pricing_defects: BTreeSet::new(),
1348 cost_live_pricing_unusable_defects: BTreeSet::new(),
1349 cost_route_receipts: BTreeSet::new(),
1350 cost_coverage_unknown_legacy: false,
1351 turn_cache_history: VecDeque::new(),
1352 last_cache_inspection: None,
1353 last_warmup_key: None,
1354 last_tool_catalog: None,
1355 last_tool_request_snapshot: None,
1356 last_base_url: None,
1357 }
1358 }
1359 }
1360
1361 impl SessionState {
1362 /// Reset the accumulated token breakdown fields to zero.
1363 pub fn reset_token_breakdown(&mut self) {
1364 self.total_input_tokens = 0;
1365 self.total_cache_hit_tokens = 0;
1366 self.total_cache_miss_tokens = 0;
1367 self.total_cache_write_tokens = 0;
1368 self.total_output_tokens = 0;
1369 self.subagent_cache_hit_tokens = None;
1370 self.subagent_cache_miss_tokens = None;
1371 self.subagent_cache_write_tokens = None;
1372 self.clear_pending_turn_usage();
1373 }
1374
1375 /// Add one provider-reported model-call receipt to the display-only
1376 /// in-flight ledger. The cache split mirrors the authoritative
1377 /// `TurnComplete` accounting path.
1378 pub fn accrue_pending_turn_usage(&mut self, usage: &Usage) {
1379 self.pending_turn_total_tokens = self
1380 .pending_turn_total_tokens
1381 .saturating_add(usage.input_tokens.saturating_add(usage.output_tokens));
1382 self.pending_turn_input_tokens = self
1383 .pending_turn_input_tokens
1384 .saturating_add(usage.input_tokens);
1385 self.pending_turn_output_tokens = self
1386 .pending_turn_output_tokens
1387 .saturating_add(usage.output_tokens);
1388 if usage.prompt_cache_hit_tokens.is_some()
1389 || usage.prompt_cache_miss_tokens.is_some()
1390 || usage.prompt_cache_write_tokens.is_some()
1391 {
1392 let classes = crate::pricing::token_usage_for_pricing(usage);
1393 self.pending_turn_cache_hit_tokens = self
1394 .pending_turn_cache_hit_tokens
1395 .saturating_add(u32::try_from(classes.cache_read).unwrap_or(u32::MAX));
1396 self.pending_turn_cache_miss_tokens = self
1397 .pending_turn_cache_miss_tokens
1398 .saturating_add(u32::try_from(classes.input).unwrap_or(u32::MAX));
1399 self.pending_turn_cache_write_tokens = self
1400 .pending_turn_cache_write_tokens
1401 .saturating_add(u32::try_from(classes.cache_write).unwrap_or(u32::MAX));
1402 }
1403 }
1404
1405 /// Clear the active turn's display-only token deltas before the
1406 /// authoritative cumulative usage is reconciled.
1407 pub fn clear_pending_turn_usage(&mut self) {
1408 self.pending_turn_total_tokens = 0;
1409 self.pending_turn_input_tokens = 0;
1410 self.pending_turn_output_tokens = 0;
1411 self.pending_turn_cache_hit_tokens = 0;
1412 self.pending_turn_cache_miss_tokens = 0;
1413 self.pending_turn_cache_write_tokens = 0;
1414 }
1415
1416 pub fn displayed_total_tokens(&self) -> u32 {
1417 self.total_tokens
1418 .saturating_add(self.pending_turn_total_tokens)
1419 }
1420
1421 pub fn displayed_total_conversation_tokens(&self) -> u32 {
1422 self.total_conversation_tokens
1423 .saturating_add(self.pending_turn_total_tokens)
1424 }
1425
1426 pub fn displayed_total_input_tokens(&self) -> u32 {
1427 self.total_input_tokens
1428 .saturating_add(self.pending_turn_input_tokens)
1429 }
1430
1431 pub fn displayed_total_output_tokens(&self) -> u32 {
1432 self.total_output_tokens
1433 .saturating_add(self.pending_turn_output_tokens)
1434 }
1435
1436 pub fn displayed_total_cache_hit_tokens(&self) -> u32 {
1437 self.total_cache_hit_tokens
1438 .saturating_add(self.pending_turn_cache_hit_tokens)
1439 }
1440
1441 pub fn displayed_total_cache_miss_tokens(&self) -> u32 {
1442 self.total_cache_miss_tokens
1443 .saturating_add(self.pending_turn_cache_miss_tokens)
1444 }
1445
1446 pub fn displayed_total_cache_write_tokens(&self) -> u32 {
1447 self.total_cache_write_tokens
1448 .saturating_add(self.pending_turn_cache_write_tokens)
1449 }
1450 }
1451
1452 /// Evidence collected during a turn for the post-turn receipt.
1453 #[derive(Debug, Clone)]
1454 pub struct ToolEvidence {
1455 pub tool_name: String,
1456 pub summary: String,
1457 }
1458
1459 #[derive(Debug, Clone)]
1460 pub(crate) struct PendingProviderSwitch {
1461 pub previous_provider: ProviderKind,
1462 pub previous_model: String,
1463 pub previous_model_ids_passthrough: bool,
1464 pub previous_route_limits: Option<RouteLimits>,
1465 pub previous_route_base_url: String,
1466 pub previous_context_window_source: crate::route_runtime::ContextWindowSource,
1467 pub previous_context_window_override: Option<u32>,
1468 pub previous_config: Config,
1469 pub previous_onboarding: OnboardingState,
1470 pub previous_onboarding_needs_api_key: bool,
1471 pub previous_api_key_env_only: bool,
1472 }
1473
1474 /// Opaque completion returned by a spawned dispatch task. It carries the
1475 /// captured data needed to apply success or rollback on the event loop.
1476 pub type DispatchApplyFn = Box<
1477 dyn FnOnce(
1478 &mut App,
1479 &crate::core::engine::EngineHandle,
1480 &crate::config::Config,
1481 ) -> anyhow::Result<()>
1482 + Send,
1483 >;
1484
1485 /// Global UI state for the TUI.
1486 #[allow(clippy::struct_excessive_bools)]
1487 /// A route change made in-session that the user has not yet decided how to
1488 /// save. Route changes are temporary by default; persisting them requires an
1489 /// explicit choice (Update this Fleet / Save as a new Fleet / Remember as my
1490 /// default / Keep for this session only).
1491 #[derive(Debug, Clone, PartialEq, Eq)]
1492 pub struct PendingRouteSave {
1493 /// Provider identity the session is now on.
1494 pub provider_identity: String,
1495 /// Exact model id the session is now on.
1496 pub model: String,
1497 /// The selected Fleet at change time, when one exists.
1498 pub fleet: Option<(String, crate::fleet::store::FleetScope)>,
1499 }
1500
1501 /// Write `provider_identity`/`model` to the user-global config as the route the next
1502 /// launch should open with, and return the line to show the operator.
1503 fn persist_route_as_startup_default(
1504 identity: &crate::config::ProviderIdentity,
1505 model: &str,
1506 ) -> String {
1507 let route = format!("{}/{model}", identity.key);
1508 match try_persist_route_as_startup_default(identity, model) {
1509 Ok(()) => format!("Remembered {route} as the startup default (config.toml)."),
1510 Err(err) => format!("Save failed: {err}"),
1511 }
1512 }
1513
1514 fn try_persist_route_as_startup_default(
1515 identity: &crate::config::ProviderIdentity,
1516 model: &str,
1517 ) -> anyhow::Result<()> {
1518 let path = crate::config::home_config_path()
1519 .ok_or_else(|| anyhow::anyhow!("Cannot resolve the user-global model configuration."))?;
1520 crate::config_persistence::persist_provider_selection(Some(&path), identity, Some(model))
1521 .map(|_| ())
1522 }
1523
1524 /// Caller scope captured by a background catalog scan; results never install
1525 /// into a different workspace, plugin snapshot or extension lifetime.
1526 #[derive(Clone)]
1527 pub(crate) struct SkillCacheScope {
1528 pub(crate) epoch: u64,
1529 pub(crate) workspace: std::path::PathBuf,
1530 pub(crate) skills_dir: std::path::PathBuf,
1531 pub(crate) mode: crate::skills::SkillDiscoveryMode,
1532 pub(crate) plugins: std::sync::Arc<crate::plugins::PluginRegistry>,
1533 }
1534
1535 pub struct App {
1536 pub mode: AppMode,
1537 /// Registered hotbar actions available for future slot config/render layers.
1538 pub hotbar_actions: HotbarActionRegistry,
1539 /// Composer sub-state (input, cursor, history, menus).
1540 pub composer: ComposerState,
1541 /// Viewport sub-state (scroll, cache, selection).
1542 pub viewport: ViewportState,
1543 /// Ocean work-surface state. Kept separate from transcript/sidebar state
1544 /// so the replacement shell can be removed or promoted as one unit.
1545 pub work_surface: crate::tui::work_surface::WorkSurfaceState,
1546 pub pet_watch: crate::tui::pet_watch::PetWatch,
1547 /// Goal sub-state.
1548 pub goal: HostGoalState,
1549 /// Session sub-state (cost, tokens, telemetry).
1550 pub session: SessionState,
1551 /// Active tool restriction from custom slash command frontmatter.
1552 /// `None` means the current turn may use the normal tool set.
1553 pub active_allowed_tools: Option<Vec<String>>,
1554 /// True when the active custom slash command opted into pause/resume.
1555 pub pausable: bool,
1556 /// A route change made in-session awaits an explicit save decision. When
1557 /// set, the next key press opens the route-save prompt unless a modal is
1558 /// already open.
1559 pub pending_route_save: Option<PendingRouteSave>,
1560 /// True after Esc paused a pausable command and before it is resumed or cancelled.
1561 pub paused: bool,
1562 /// Saved custom-command objective while the command is paused.
1563 pub paused_goal_objective: Option<String>,
1564 pub history: Vec<HistoryCell>,
1565 pub history_version: u64,
1566 /// Bumped when destructive reindexing could make a cached Space owner name
1567 /// a different cell before redraw; ordinary streaming revisions preserve it.
1568 pub(crate) transcript_identity_epoch: u64,
1569 /// Per-cell revision counter, kept in lockstep with `history`.
1570 pub history_revisions: Vec<u64>,
1571 /// Cached tool-run grouping for transcript collapse. The detector is
1572 /// keyed by the same mutation generation that invalidates transcript
1573 /// cells, so idle frames do not rescan the full history.
1574 pub(crate) tool_run_cache: ToolRunCache,
1575 /// Monotonic counter used to issue fresh per-cell revisions.
1576 pub next_history_revision: u64,
1577 /// Engine transcript mirror, shared rather than copied per event
1578 /// (#6214 T2). Reads dereference to the `Vec`; mutations go through
1579 /// [`App::api_messages_mut`] and copy-on-write only while an engine
1580 /// snapshot is outstanding.
1581 pub api_messages: Arc<Vec<Message>>,
1582 /// When each `api_messages` entry landed, index-aligned. The persisted
1583 /// journal's `created_at` reads from these stamps, so a save rewrites
1584 /// neither an entry's content nor its time — appends during a turn stay
1585 /// spread across the session's real timeline instead of collapsing to
1586 /// the save instant. Maintained by the `*_api_messages` helpers; a
1587 /// length-mismatched site degrades to save-time stamps, never to a
1588 /// dropped message.
1589 pub api_message_stamps: Vec<DateTime<Utc>>,
1590 /// Full saved history, including inactive branches. API messages remain
1591 /// the active projection; snapshots reconcile it without rebuilding IDs.
1592 pub session_journal: crate::session_tree::SessionJournal,
1593 /// User-visible assistant text that crossed typed completion boundaries.
1594 /// Receipts are aligned to transcript cells because provider context can
1595 /// be compacted or purged without changing what remains visible.
1596 completed_assistant_outputs: Vec<CompletedAssistantOutputReceipt>,
1597 pub(crate) context_token_cache: RefCell<ContextTokenCache>,
1598 /// Typed account-owned browser relay for this exact TUI session.
1599 pub remote_control: crate::remote_control::RemoteControlController,
1600 pub start_remote_control_on_launch: bool,
1601 pub is_loading: bool,
1602 /// One local report edit awaiting the ordinary composer dispatch.
1603 pub(crate) feedback_dispatch: Option<crate::tui::ui::feedback_host::EditReady>,
1604 /// Sender for spawned dispatch tasks to report completion back to the
1605 /// event loop. The closure is called with `&mut App` so the async phase
1606 /// never needs `&mut App` while awaiting network I/O (#4605).
1607 pub dispatch_completion_tx: Option<tokio::sync::mpsc::Sender<DispatchApplyFn>>,
1608 /// True while a spawned dispatch task is in flight (#4605). Set in the
1609 /// sync prepare phase and cleared when the completion closure runs, so a
1610 /// submit after an Esc-cancel (which clears `is_loading`) still queues
1611 /// instead of spawning a second dispatch that could reorder ops.
1612 pub dispatch_in_flight: bool,
1613 /// Cancels the in-flight dispatch task (#6800). Tripped by a local turn
1614 /// cancel or stall recovery so the dispatch fails back to the composer
1615 /// instead of holding `dispatch_in_flight` for its whole bound.
1616 pub dispatch_cancel: Option<tokio_util::sync::CancellationToken>,
1617 /// Timestamp of the most recent Enter while the engine was busy.
1618 /// Used by `enter_with_double_tap()` / `double_tap_window_open()` to
1619 /// detect a second Enter inside [`Self::DOUBLE_TAP_WINDOW`].
1620 pub last_enter_instant: Option<Instant>,
1621 /// Whether the once-per-turn provider-wait incident (#3095) has already
1622 /// been logged for the current turn.
1623 pub provider_wait_incident_logged: bool,
1624 /// Ghost-text follow-up suggestion shown in the composer when empty.
1625 /// Generated asynchronously after each completed turn; cleared on new input.
1626 pub prompt_suggestion: Option<String>,
1627 /// Read-only view of the current Config, refreshed by its notification delta owner.
1628 pub notification_settings: crate::config::NotificationsConfig,
1629 /// Monotonic turn counter for stale-suggestion protection. Incremented on
1630 /// each TurnStarted; background suggestion tasks capture the token and
1631 /// discard their result if the token no longer matches.
1632 pub prompt_suggestion_gen: std::sync::atomic::AtomicU64,
1633 /// Degraded connectivity mode; new user inputs are queued for later retry.
1634 pub offline_mode: bool,
1635 /// Whether an `EngineEvent::Error` has already been posted for the
1636 /// current turn. Suppresses the redundant "Turn failed:" status line
1637 /// that `TurnComplete { error: .. }` would otherwise emit on top of
1638 /// the in-transcript error cell.
1639 pub turn_error_posted: bool,
1640 /// Text of the error cell posted for the current turn, when
1641 /// `turn_error_posted`. A turn that then ends `Failed` persists exactly
1642 /// this text, so resume shows what the live transcript showed.
1643 pub(crate) turn_error_notice: Option<String>,
1644 /// Legacy status text sink retained for compatibility with existing call sites.
1645 pub status_message: Option<String>,
1646 /// Recent status toasts (ephemeral, newest at back).
1647 pub status_toasts: VecDeque<StatusToast>,
1648 /// Header chip label (e.g. `↑ v0.9.5`) set once by the fire-and-forget
1649 /// startup version check when a newer stable release exists. Drives the
1650 /// small persistent update chip in the header so the affordance survives
1651 /// the transient toast without nagging (FINISH-0.9.4 #14).
1652 pub update_available: Option<String>,
1653 /// Sticky status toast used for important warnings/errors.
1654 pub sticky_status: Option<StatusToast>,
1655 /// Last status text already promoted from `status_message` into toast state.
1656 pub last_status_message_seen: Option<String>,
1657 /// Prevents the same pressure condition from immediately re-arming after
1658 /// the operator explicitly dismisses its sticky warning. Reset when the
1659 /// pressure falls below the warning threshold or compaction starts.
1660 pub context_pressure_warning_dismissed: Option<crate::context_budget::PressureLevel>,
1661 /// Last on-disk plugin catalog stamp we already nudged `/plugin reload` for.
1662 pub plugin_reload_nudge_stamp: Option<crate::plugins::PluginCatalogStamp>,
1663 /// Last idle catalog fingerprint poll, so disk changes can surface between turns.
1664 pub last_plugin_catalog_poll: Option<Instant>,
1665 /// Live composer plugin CTA (debounce + one match, never auto-install).
1666 pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
1667 pub model: String,
1668 /// Persisted model selections by provider name. Loaded from settings so
1669 /// `/model` and the picker can surface saved provider-specific choices.
1670 pub provider_models: HashMap<String, String>,
1671 /// Which routes this person actually used recently (#6533): built from
1672 /// saved sessions off the UI thread at startup, bumped on route switches.
1673 /// The `/model` picker's default view ranks by it.
1674 pub route_usage: crate::model_relevance::SharedRouteUsage,
1675 /// Non-secret declarations from the loaded config snapshot. Completion
1676 /// reads this snapshot without reloading credentials on each keystroke.
1677 pub configured_models: Vec<codewhale_config::catalog::configured::ConfiguredModel>,
1678 /// Exact provider/model pins loaded from settings, in user order.
1679 pub pinned_models: Vec<crate::settings::PinnedModel>,
1680 /// When true, the model is auto-selected rather than using a fixed
1681 /// model. The `/model auto` command sets this. The flash classifier
1682 /// picks the per-turn model when available; otherwise the configured
1683 /// default model is used (no request-content signal).
1684 pub auto_model: bool,
1685 /// Last concrete model chosen while `auto_model` is active.
1686 pub last_effective_model: Option<String>,
1687 /// Provider that actually served the latest auto-routed turn.
1688 pub last_effective_provider: Option<ProviderKind>,
1689 /// Exact non-secret identity for the provider that served the latest Auto
1690 /// turn. This matters for named custom providers, which all share the
1691 /// `ProviderKind::Custom` enum variant.
1692 pub(crate) last_effective_provider_identity: Option<String>,
1693 /// Auto decision metadata for the most recently resolved Auto turn.
1694 pub(crate) last_auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
1695 /// Route selected for the next turn, retained for in-flight UI details
1696 /// until the engine confirms the authoritative `TurnStarted` route.
1697 pub pending_turn_route: Option<(ProviderKind, String, bool)>,
1698 /// Auto decision metadata waiting to be paired with `pending_turn_route`.
1699 pub(crate) pending_auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
1700 /// Authoritative lifecycle metadata attached to the most recent
1701 /// `TurnStarted`. Kept separate from `pending_turn_route` so a preceding
1702 /// compaction completion cannot consume the next model turn's route.
1703 pub active_turn: Option<ActiveTurnMetadata>,
1704 /// Current API provider (mirrors `Config::api_provider`).
1705 /// Updated by `/provider` switches so the UI/commands can read the
1706 /// active backend without re-deriving it from the live config.
1707 pub api_provider: ProviderKind,
1708 /// The resolved startup config named a provider or model. Capture this
1709 /// before runtime synchronization writes even the built-in route to Config;
1710 /// missing credentials must not make that choice eligible for discovery.
1711 pub(crate) startup_route_configured: bool,
1712 /// Exact configured provider key for persistence and route restoration.
1713 /// Built-ins use their canonical slug; named custom providers retain the
1714 /// user-owned key instead of collapsing to `custom`.
1715 pub(crate) provider_identity: Option<crate::config::ProviderIdentity>,
1716 /// Primary provider plus configured fallback providers for this session.
1717 pub provider_chain: Option<ProviderChain>,
1718 /// Per-provider auth/local readiness snapshot for the fallback chain (#2574).
1719 ///
1720 /// Captured at startup alongside `provider_chain` (where the live `Config` is
1721 /// in scope). `advance_fallback` consults it to skip chain entries that
1722 /// cannot serve a turn — hosted providers missing a key — while local
1723 /// providers (Ollama/vLLM/SGLang) are always ready. Stored as `(provider,
1724 /// ready)` pairs; lookups fall back to "ready" for providers not present so
1725 /// an unknown entry is tried rather than silently skipped.
1726 provider_readiness: Vec<(crate::config::ProviderIdentity, bool)>,
1727 /// Session-local evidence from real provider requests and verification
1728 /// probes. Unlike `provider_readiness` above, this never treats a saved key
1729 /// as proof that the endpoint is healthy.
1730 pub(crate) provider_health: crate::provider_readiness::ProviderReadinessSnapshot,
1731 /// Human-readable description of the last provider fallback event.
1732 pub last_fallback_reason: Option<String>,
1733 /// True when the active provider/base URL accepts arbitrary model IDs
1734 /// verbatim rather than DeepSeek-only aliases.
1735 pub model_ids_passthrough: bool,
1736 /// Resolved provider/model route limits for the active runtime route.
1737 pub active_route_limits: Option<RouteLimits>,
1738 /// Exact resolved endpoint for the active runtime route. This stays
1739 /// separate from persisted config so endpoint-sensitive compatibility
1740 /// (notably Kimi Code's bare `k3`) is never inferred from a provider name
1741 /// alone.
1742 pub active_route_base_url: String,
1743 /// Provenance for `active_route_limits`' effective context window. This
1744 /// is an operator-facing receipt, not a claim about provider billing.
1745 pub active_context_window_source: crate::route_runtime::ContextWindowSource,
1746 /// User-configured provider context-window override for the active route.
1747 pub active_context_window_override: Option<u32>,
1748 /// `[providers.<id>.model_context_windows]` for the active provider
1749 /// identity, keyed by exact wire model id (#6108). A hit wins over
1750 /// `active_context_window_override` for that model only.
1751 pub active_model_context_windows: Option<std::collections::BTreeMap<String, u32>>,
1752 /// Pending provider transition for transactional rollback when the next
1753 /// auth failure indicates the new provider cannot be used.
1754 pub pending_provider_switch: Option<PendingProviderSwitch>,
1755 /// Current live reasoning-effort selection. Route changes may normalize
1756 /// this value; the raw user choice remains in
1757 /// [`Self::reasoning_effort_preference`].
1758 pub reasoning_effort: ReasoningEffort,
1759 /// Raw explicit user preference, before any fixed provider/model route
1760 /// normalizes it. `None` means the current live tier is an implicit route
1761 /// default or compatibility inference and must not constrain Auto routing.
1762 pub(crate) reasoning_effort_preference: Option<ReasoningEffort>,
1763 /// Last effective thinking receipt for the most recently accepted route.
1764 pub(crate) last_effective_reasoning_effort: Option<EffectiveReasoningEffort>,
1765 pub workspace: PathBuf,
1766 /// Effective `[workflow]` table for this session (`/workflow settings`).
1767 pub workflow_config: codewhale_config::WorkflowConfigToml,
1768 /// Effective `[goal] max_continuations` backstop; `0` means unlimited.
1769 pub goal_max_continuations: u32,
1770 /// Effective `[goal] enforce_token_budget`; `true` makes a goal's token
1771 /// budget a hard stop instead of advisory telemetry (#6013).
1772 pub goal_enforce_token_budget: bool,
1773 /// Typed engine lifecycle state for the cancellable between-turn wait.
1774 pub goal_continuation_waiting: bool,
1775 /// Effective explicit/managed filesystem scope captured at startup. The
1776 /// named permission posture supplies the default when this is `None`.
1777 pub configured_sandbox_mode: Option<String>,
1778 /// Configured `sandbox_network_access`. `None`/`Some(false)` keep the
1779 /// workspace-write sandbox network-restricted.
1780 pub configured_sandbox_network: Option<bool>,
1781 /// The sandbox backend this platform+config can actually enforce with,
1782 /// resolved once at startup. `None` means there is NO enforcement
1783 /// available (default Linux without `prefer_bwrap`, and all Windows), so
1784 /// surfaces must not claim the session is sandboxed (2026-08-04 audit).
1785 pub sandbox_backend: Option<crate::sandbox::SandboxType>,
1786 /// Off-event-loop worker for durable Lane control writes. `/lane interrupt`
1787 /// submits here instead of tearing down a Runtime on the composer thread
1788 /// (#4022).
1789 pub lane_control: crate::lane_control::LaneControlQueue,
1790 /// Immutable plugin catalogue scoped to this App's effective workspace.
1791 pub plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
1792 pub config_path: Option<PathBuf>,
1793 pub config_profile: Option<String>,
1794 /// Legacy executable plugin-tool directory resolved from the already
1795 /// loaded configuration. Slash-command inventory must not reload the full
1796 /// config (and thereby re-read credential-bearing fields) merely to find
1797 /// this path.
1798 pub legacy_plugin_tools_dir: Option<PathBuf>,
1799 pub mcp_config_path: PathBuf,
1800 pub skills_dir: PathBuf,
1801 pub skills_discovery_mode: crate::skills::SkillDiscoveryMode,
1802 /// Whether the optional project context pack was enabled when this
1803 /// session loaded its configuration. Context diagnostics consult this
1804 /// source of truth even before the first system prompt is assembled.
1805 pub project_context_pack_enabled: bool,
1806 /// Path to the user-memory file (#489). Always populated; only
1807 /// consulted when `use_memory` is `true`.
1808 pub memory_path: PathBuf,
1809 /// Whether the user-memory feature is enabled (#489). Mirrors
1810 /// `Config::memory_enabled()` at app boot. Used by the `# foo`
1811 /// composer interception,
1812 /// the `/memory` slash command, and tool registration for
1813 /// `remember`.
1814 pub use_memory: bool,
1815 /// Screen the TUI is painting on right now. `/fullscreen` and `/inline`
1816 /// move it at runtime; `use_alt_screen()` is derived from it so no second
1817 /// flag can drift out of step with the live terminal.
1818 pub screen_mode: ScreenMode,
1819 /// Mouse capture as programmed on the live terminal. Re-derived from
1820 /// `mouse_capture_preference` and `screen_mode` on every screen switch.
1821 pub use_mouse_capture: bool,
1822 /// See [`TuiOptions::mouse_capture_preference`].
1823 pub mouse_capture_preference: bool,
1824 /// When true, plain Up/Down on an empty composer scroll the transcript
1825 /// instead of navigating input history. Defaults to `true` when mouse
1826 /// capture is off: terminals that convert mouse-wheel events to arrow-key
1827 /// sequences (e.g. Windows CMD without `WT_SESSION`) get page-scrolling
1828 /// without any explicit config (#1443).
1829 pub composer_arrows_scroll: bool,
1830 /// Whether `composer_arrows_scroll` came from explicit configuration.
1831 pub composer_arrows_scroll_explicit: bool,
1832 /// Data-side cap for the `@`-mention popup. The renderer still limits the
1833 /// visible rows to available terminal height.
1834 pub mention_menu_limit: usize,
1835 /// Maximum workspace depth for `@`-mention completion walks. `0` means
1836 /// unlimited depth.
1837 pub mention_walk_depth: usize,
1838 /// `@`-mention completion behavior: fuzzy workspace search or deterministic
1839 /// directory browser.
1840 pub mention_menu_behavior: String,
1841 /// Follow symbolic links during workspace file discovery walks.
1842 /// When `true`, symlinked directories are traversed, enabling
1843 /// multi-project workspaces.
1844 pub workspace_follow_symlinks: bool,
1845 pub use_bracketed_paste: bool,
1846 pub use_paste_burst_detection: bool,
1847 /// Set to `true` the first time a real `Event::Paste` arrives during a
1848 /// session. Once set, `handle_paste_burst_key` short-circuits — there's
1849 /// no point running the rapid-keypress heuristic on a terminal that
1850 /// already delivers paste-as-event correctly. Avoids paste-burst false
1851 /// positives on Ghostty / iTerm2 / WezTerm / Windows Terminal where
1852 /// fast typing or IME commits could otherwise be mis-classified as a
1853 /// paste burst (#1322 follow-up).
1854 pub bracketed_paste_seen: bool,
1855 /// A non-Windows terminal on the verified `Event::Paste` allowlist may
1856 /// skip the rapid-keystroke heuristic from the first keystroke. Windows
1857 /// input requires `bracketed_paste_seen`: a terminal name or WT_SESSION
1858 /// does not prove that the input backend delivers paste events (#6427).
1859 /// Resolved once at startup, so tests and headless runs stay hermetic.
1860 pub bracketed_paste_trusted: bool,
1861 pub system_prompt: Option<SystemPrompt>,
1862 pub auto_compact: bool,
1863 pub auto_compact_user_configured: bool,
1864 pub auto_compact_threshold_percent: f64,
1865 /// `[compaction] summary_instructions` resolved at startup (#5956): the
1866 /// standing operator suffix appended to every summarizer prompt, manual
1867 /// and automatic.
1868 pub compaction_summary_instructions: Option<String>,
1869 /// `[compaction] retained_user_message_tokens` resolved and clamped at
1870 /// startup (#5956).
1871 pub compaction_retained_user_message_tokens: usize,
1872 pub stopped_turn: bool,
1873 pub calm_mode: bool,
1874 pub low_motion: bool,
1875 pub constrained_frame_rate: bool,
1876 /// The ambient animation clock, in clamped milliseconds. Creature and
1877 /// water positions are pure functions of this value; advancing it by at
1878 /// most [`App::AMBIENT_MAX_STEP_MS`] per sampled frame keeps motion
1879 /// continuous when draws arrive in bursts (fast token streams previously
1880 /// sampled raw wall-clock time at irregular gaps, so fish "teleported"
1881 /// between frames — captains-log #16).
1882 pub ambient_clock_ms: u128,
1883 /// When the ambient clock last advanced; `None` until the first sample.
1884 pub ambient_clock_sampled_at: Option<Instant>,
1885 /// When the shell last became fully idle (no turn, no live sub-agents,
1886 /// no active durable tasks, completion exhale finished). After a short
1887 /// grace of gentle motion the aquarium settles to a genuinely still
1888 /// scene instead of repainting an idle screen forever.
1889 pub ambient_idle_since: Option<Instant>,
1890 /// Start of the underwater shell's one-shot successful-turn exhale.
1891 /// Kept separate from the ambient ocean clock so completion can settle
1892 /// once without restarting or repainting the transcript field.
1893 pub ocean_completion_started_at: Option<Instant>,
1894 /// History length at the current turn boundary. Successful completion
1895 /// uses this stable index to settle only the receipts produced by that
1896 /// turn, never old transcript rows.
1897 pub ocean_turn_history_start: usize,
1898 /// First committed history cell participating in the current one-shot
1899 /// receipt-settle cascade.
1900 pub ocean_receipt_settle_start: Option<usize>,
1901 /// Enables the authored underwater phase and ambient motion system.
1902 pub fancy_animations: bool,
1903 /// Typed appearance treatment; appearance is independent from motion
1904 /// settings, and every underwater treatment keeps ambient life.
1905 /// Focus-context texture prototype mode (#4823), parsed once from the
1906 /// `focus_texture` setting. `Off` by default; while off the modal render
1907 /// path is byte-identical to the pre-prototype path.
1908 pub focus_texture: crate::tui::focus_texture::FocusTextureMode,
1909 /// Distinct pre-session menu. Once dismissed, the normal idle ocean owns
1910 /// the empty session and this state stays hidden.
1911 pub launch: LaunchState,
1912 /// Mouse-selected launch action, consumed by the async UI loop.
1913 pub pending_launch_action: Option<crate::tui::underwater::LaunchAction>,
1914 /// Mouse click on the live composer's `[↵]` send target. The async UI loop
1915 /// consumes it through the same submit dispatcher as Enter.
1916 pub pending_composer_submit: Option<ComposerSubmitChord>,
1917 /// Mouse-selected hotbar slot, consumed by the async UI loop.
1918 pub pending_hotbar_slot: Option<u8>,
1919 /// Whether the renderer should wrap each frame in DEC mode 2026
1920 /// synchronized output. Resolved from `Settings::synchronized_output`
1921 /// at construction; `auto`/`on` → `true`, `off` → `false`. The Ptyxis
1922 /// auto-detect path in `Settings::apply_env_overrides` flips `auto`
1923 /// to `off` before App is built, so by the time we read this flag in
1924 /// the draw loop the decision is already made. See the
1925 /// `Settings::synchronized_output` doc for the user-facing knob.
1926 pub synchronized_output_enabled: bool,
1927 /// Header status-indicator chip mode. `"cw"` is the static default;
1928 /// `"whale"` and `"dots"` preserve the animated legacy choices, while
1929 /// `"off"` hides the chip. Loaded from settings and changed via
1930 /// `/config status_indicator <cw|whale|dots|off>`.
1931 pub status_indicator: String,
1932 pub show_thinking: bool,
1933 pub thinking_highlight: bool,
1934 pub thinking_default_expanded: bool,
1935 pub thinking_preview_lines: usize,
1936 pub help_expand_groups: bool,
1937 pub pin_last_prompt: bool,
1938 pub verbose_transcript: bool,
1939 pub show_tool_details: bool,
1940 /// Inline presentation mode for successful structured File mutations.
1941 /// Exact evidence remains attached to each mutation receipt in all modes.
1942 pub inline_diff_mode: InlineDiffMode,
1943 pub ui_locale: Locale,
1944 pub cost_currency: CostCurrency,
1945 /// Route payment truth. Model pricing alone cannot distinguish metered
1946 /// API calls from OAuth or token-plan quota.
1947 pub billing_presentation: crate::route_billing::BillingPresentation,
1948 pub composer_density: ComposerDensity,
1949 pub composer_border: bool,
1950 pub composer_multiline_mode: bool,
1951 /// Voice input state — toggled by `/voice` and the voice hotbar action.
1952 pub voice_enabled: bool,
1953 /// Auto-send after transcription when the transcript ends with an
1954 /// explicit send instruction ("send it" / "发送"). Toggled by `/voice-send`.
1955 pub voice_send_enabled: bool,
1956 /// AI-assisted dictation that sees the current composer text.
1957 /// Toggled by `/voice-control`.
1958 pub voice_control_enabled: bool,
1959 pub transcript_spacing: TranscriptSpacing,
1960 /// Prose wrap cap from `[transcript] prose_measure` (#5436). `None`
1961 /// means prose uses the full content width, like tool/status cells.
1962 pub(crate) prose_measure: Option<u16>,
1963 /// Sidebar hover state for mouse tooltip support.
1964 pub sidebar_hover: SidebarHoverState,
1965 /// Current hover tooltip text, if any.
1966 pub sidebar_hover_tooltip: Option<String>,
1967 /// Last successfully rendered Work panel summary. Transient mutex misses
1968 /// Browsing context from the last dismissed `/model` picker, so reopening
1969 /// restores the view mode and highlighted row instead of resetting to the
1970 /// top (#4109 picker memory). Session-scoped, never persisted.
1971 pub model_picker_memory: Option<ModelPickerMemory>,
1972 /// Browsing context from the last dismissed `/provider` picker.
1973 pub provider_picker_memory: Option<ProviderPickerMemory>,
1974 /// Last known mouse position for tooltip placement.
1975 pub last_mouse_pos: Option<(u16, u16)>,
1976 /// Whether the session-context panel is enabled (#504).
1977 pub context_panel: bool,
1978 /// Whether the persistent Sessions rail is enabled (#2934). Opt-in.
1979 pub sessions_rail: bool,
1980 /// Minimum number of consecutive safe tool cells needed for auto-collapse.
1981 ///
1982 /// Fixed at 3 for v0.9.x (#3256 decision): not a user setting. Rollups need
1983 /// enough cells to be readable; exposing a knob without UX for partial
1984 /// runs would just recreate the pre-collapse noise floor.
1985 pub tool_collapse_threshold: usize,
1986 /// Tool runs the user explicitly expanded. Stores original history indices.
1987 pub expanded_tool_runs: HashSet<usize>,
1988 /// Current dense tool-run collapse behavior.
1989 pub tool_collapse_mode: ToolCollapseMode,
1990 /// File-tree pane state. `None` when hidden; `Some` when visible.
1991 pub file_tree: Option<crate::tui::file_tree::FileTreeState>,
1992 /// Whether the file-tree pane was actually rendered in the last frame.
1993 /// Set false when the terminal is too narrow to show the tree.
1994 pub file_tree_visible: bool,
1995 pub compact_threshold: usize,
1996 pub max_input_history: usize,
1997 pub allow_shell: bool,
1998 pub verbosity: Option<String>,
1999 pub max_subagents: usize,
2000 /// Per-SSE-chunk idle timeout for streamed turns, in seconds.
2001 pub stream_chunk_timeout_secs: u64,
2002 /// Cached sub-agent snapshots for UI views.
2003 pub subagent_cache: Vec<SubAgentResult>,
2004 /// First time this TUI observed each terminal sub-agent card.
2005 pub subagent_terminal_seen_at: HashMap<String, Instant>,
2006 /// Last known per-agent progress text for running sub-agents.
2007 pub agent_progress: HashMap<String, String>,
2008 /// Parent/depth metadata for live progress-only sub-agent rows.
2009 pub agent_progress_meta: HashMap<String, AgentProgressMeta>,
2010 /// In-transcript sub-agent card index by `agent_id` (issue #128).
2011 /// Maps each live sub-agent to the `HistoryCell::SubAgent` it renders
2012 /// into, so successive mailbox envelopes mutate the same cell rather
2013 /// than spawning duplicates.
2014 pub subagent_card_index: HashMap<String, usize>,
2015 /// History index of the most recent FanoutCard. Sibling sub-agents
2016 /// spawned by the same `rlm` invocation route into this card; reset
2017 /// when a fresh fanout-family tool call starts.
2018 pub last_fanout_card_index: Option<usize>,
2019 /// Most recently observed sub-agent dispatch tool name (set on
2020 /// `ToolCallStarted` for `agent` / `rlm` / etc., cleared
2021 /// after the first `Started` mailbox envelope routes through it).
2022 pub pending_subagent_dispatch: Option<String>,
2023 /// Animation anchor for status-strip active sub-agent spinner.
2024 pub agent_activity_started_at: Option<Instant>,
2025 /// Monotonic counter for stable agent labels (#3030).
2026 /// Incremented each time a sub-agent is spawned; used to generate
2027 /// "Agent 1", "Agent 2", etc.
2028 pub agent_counter: u64,
2029 /// Maps raw agent_id to a stable user-facing label (#3030).
2030 /// Populated when `AgentSpawned` fires; read by sidebar rendering.
2031 pub agent_label_map: HashMap<String, String>,
2032 /// Background work (agents, shells, durable tasks) that finished since
2033 /// the last notice, named the way every surface names it. Drained by one
2034 /// batched notice (#6565).
2035 pub background_finished: Vec<crate::tui::background_finished::FinishedWork>,
2036 /// Background shells by owning session, oldest first, capped per session
2037 /// at [`crate::tui::background_finished::MAX_FINISHED_SHELLS`]. They stay listed, muted, so
2038 /// a person can see what ran and how it ended (#6565).
2039 pub finished_shell_ids: HashMap<String, VecDeque<String>>,
2040 /// Completion deduplication is independent of visible rows and their cap.
2041 /// IDs are manager-unique; these sets live only for this TUI process.
2042 pub notified_shell_ids: HashSet<String>,
2043 pub notified_task_ids: HashSet<String>,
2044 /// When the latest `AgentList` snapshot arrived, so a running agent's
2045 /// engine idle clock keeps counting between snapshots.
2046 pub subagent_cache_received_at: Option<Instant>,
2047 /// The child whose full transcript currently owns the main conversation
2048 /// area and whose fork the composer addresses (`None` = main session).
2049 pub agent_focus: Option<crate::tui::agent_focus::AgentFocus>,
2050 /// Follow-ups a running child has not yet taken at its next round
2051 /// boundary (`agent_id` → count), from the latest `AgentList` refresh.
2052 pub agent_queued_follow_ups: HashMap<String, usize>,
2053 /// Receipts-only roster of every agent that ran this session (#5479).
2054 /// Refreshed wholesale on each `AgentList` event; shared by `/agents`,
2055 /// the Agents register and the Price view.
2056 pub agent_roster: Vec<crate::agent_roster::AgentRosterRow>,
2057 /// Original conversation owner of the retained snapshot. A process boot
2058 /// marker or a worker's parent run is not a conversation identity.
2059 pub agent_roster_session_id: Option<String>,
2060 /// `/agents list` asked for a one-shot transcript listing. Cleared by the
2061 /// `AgentList` handler that prints it.
2062 pub agent_roster_print_requested: bool,
2063 /// Last time a sub-agent progress event triggered a redraw.
2064 /// Used to throttle redraws under high sub-agent concurrency (#3033).
2065 pub last_agent_progress_redraw: Option<Instant>,
2066 /// Last time a workflow `budget_updated` event was allowed to request a
2067 /// repaint. High-signal workflow events (task/run lifecycle) always paint;
2068 /// budget-only chatter is paced under fan-out (#4095 residual).
2069 pub last_workflow_budget_redraw: Option<Instant>,
2070 pub ui_theme: UiTheme,
2071 /// Parsed `background_color` setting, kept separately from `ui_theme` so
2072 /// an explicit override remains distinguishable even when it happens to
2073 /// equal the current named theme's default surface and can still carry
2074 /// into previews of other themes.
2075 pub background_color_override: Option<Color>,
2076 /// Active named theme. Drives the cell-level color remap in
2077 /// `tui::color_compat::ColorCompatBackend` so community presets
2078 /// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every
2079 /// render site, not just the handful that read `app.ui_theme`.
2080 pub theme_id: palette::ThemeId,
2081 /// Normalized persisted selector, including `custom:<name>` overlays.
2082 /// `theme_id` remains the resolved base theme for behavior such as the
2083 /// underwater surface and color-compatibility backend.
2084 pub theme_name: String,
2085 // Onboarding
2086 pub onboarding: OnboardingState,
2087 /// True while the startup gate for `[redaction] model_bound = "disabled"`
2088 /// owns the screen. The gate renders above every other surface and must
2089 /// be answered (confirm / keep / quit) before any session starts; see
2090 /// `tui::redaction_gate`.
2091 pub redaction_gate: bool,
2092 /// True while the gate shows its second, final-confirmation stage: the
2093 /// user already pressed 1/Y on the first stage and must confirm once more
2094 /// before the opt-out actually takes effect.
2095 pub redaction_gate_confirming: bool,
2096 /// Viewport position for the consent text; clamped by the gate renderer.
2097 pub redaction_gate_scroll: std::cell::Cell<usize>,
2098 pub onboarding_needs_api_key: bool,
2099 pub onboarding_provider: ProviderKind,
2100 pub onboarding_workspace_trust_gate: bool,
2101 /// True when onboarding opened only because a returning user's configured
2102 /// provider is missing its key. Esc then exits to the offline composer
2103 /// instead of walking back through first-run steps.
2104 pub onboarding_missing_key_recovery: bool,
2105 /// Why provider setup reopened after the provider refused the active
2106 /// key. The setup screen covers the transcript, so it shows this until
2107 /// the user leaves or completes setup.
2108 pub(crate) onboarding_key_rejected: Option<String>,
2109 /// True when the user explicitly chose "Explore offline" during onboarding
2110 /// (#3927). No provider was selected, no route was activated, and no secret
2111 /// was saved: the session browses with queued input until a route is
2112 /// activated later (`/provider`), which is the only thing that clears it.
2113 pub onboarding_explore_offline: bool,
2114 /// First-run route receipts: which required decisions this run contains.
2115 /// The surface title counts only these ("1 of 2"), never a fixed spine.
2116 pub onboarding_had_language_step: bool,
2117 pub onboarding_had_provider_step: bool,
2118 pub onboarding_had_trust_step: bool,
2119 /// True when the active credential was discovered only through an
2120 /// environment variable. Missing-key recovery and route rollback use this
2121 /// provenance to decide whether a durable provider slot still exists;
2122 /// credential drafts live exclusively inside `ProviderPickerView`.
2123 pub api_key_env_only: bool,
2124 // Hooks system
2125 pub hooks: HookExecutor,
2126 /// Lifecycle event outbox (`[lifecycle_outbox]` config). Disabled
2127 /// (all emits no-ops) when no path is configured.
2128 pub lifecycle_outbox: codewhale_hooks::LifecycleOutbox,
2129 pub yolo: bool,
2130 /// One-shot YOLO→Act+Bypass migration notice for this session (#0.8.68 M6).
2131 yolo_compat_notified: bool,
2132 /// The single serialized owner of `settings.toml` startup-default writes
2133 /// (mode, thinking, model). Keeping one owner per `App` is what stops two
2134 /// rapid selections from interleaving their load/modify/save transactions
2135 /// and losing the newer one. Failures are drained by the event loop into a
2136 /// warning toast, so a settings write that did not land is never silently
2137 /// reverted on the next launch.
2138 pub startup_defaults: crate::tui::startup_defaults::StartupDefaultsWriter,
2139 /// One-shot Shift+Tab/Ctrl+T rebinding notice for this session (#0.8.68 M3).
2140 keybinding_migration_notified: bool,
2141 /// Durable Agent-era permission baseline that Plan/YOLO derive from and
2142 /// restore to (#3386). Refreshed from the live fields whenever the user
2143 /// leaves Agent mode; see [`base_policy_for_mode`] and `set_mode`.
2144 mode_prefs: ModeSessionPrefs,
2145 /// True when config/requirements supplied an approval policy. In that
2146 /// case the TUI-only Shift+Tab preference must not loosen it.
2147 approval_policy_locked: bool,
2148 /// True only when the controlling policy is the user's editable root
2149 /// config.toml key. An explicit Shift+Tab may migrate that key to the
2150 /// durable TUI posture; higher-precedence sources remain immutable.
2151 approval_policy_root_editable: bool,
2152 /// True only when an organization requirements file owns approval policy.
2153 /// Unlike a user-owned config key, this source cannot be edited in-app.
2154 approval_policy_requirements_managed: bool,
2155 /// True when the interactive shell switch is user-owned (unset or root
2156 /// config.toml). Profile / env / managed / project owners stay in charge
2157 /// even when a YOLO entry point asks for Full Access.
2158 shell_access_editable: bool,
2159 // Clipboard handler
2160 pub clipboard: ClipboardHandler,
2161 // Tool approval session allowlist
2162 pub approval_session_approved: HashSet<String>,
2163 /// Approval keys (or tool names) the user has denied or aborted in
2164 /// this session. Subsequent re-requests for the same approval key
2165 /// auto-deny without re-prompting (#360) — the model can retry a
2166 /// dangerous command after being told no, but the user shouldn't
2167 /// have to keep dismissing the same dialog.
2168 pub approval_session_denied: HashSet<String>,
2169 pub approval_mode: ApprovalMode,
2170 // Modal view stack (approval/help/etc.)
2171 pub view_stack: ViewStack,
2172 /// Last `request_user_input` prompt, retained so a failed modal submit can reopen (#1198).
2173 pub pending_user_input_prompt: Option<(String, crate::tools::user_input::UserInputRequest)>,
2174 /// Child-agent approval requests shown to the person and not yet
2175 /// answered, keyed by approval id (approvals C1). The footer row, the
2176 /// `/agents` re-open, and retiring answered cards all read this store.
2177 pub pending_child_requests:
2178 std::collections::BTreeMap<String, crate::tui::pending_requests::PendingChildRequest>,
2179 /// Which conversation owns each child agent this host has seen, from the
2180 /// agent lifecycle events. A request from another conversation's child
2181 /// is answered `unavailable` instead of shown here.
2182 pub child_agent_sessions: std::collections::HashMap<String, String>,
2183 /// Esc-Esc backtrack state machine (#133). `Inactive` by default; first
2184 /// Esc primes, second Esc opens the live-transcript overlay scoped to
2185 /// previous user messages so the user can rewind a turn.
2186 pub backtrack: crate::tui::backtrack::BacktrackState,
2187 /// Current session ID for auto-save updates
2188 pub current_session_id: Option<String>,
2189 /// Exclusive editor ownership, shared with outstanding queue writes.
2190 pub(crate) offline_queue_lease:
2191 Option<std::sync::Arc<crate::session_manager::OfflineQueueLease>>,
2192 /// Last non-contended Work snapshot captured in this App. The outer
2193 /// option distinguishes "never captured" from a captured empty state.
2194 pub(crate) last_known_work_state: Option<Option<SessionWorkState>>,
2195 /// Latest bounded runtime goal projection. Persistence stores this beside
2196 /// the owning saved session so a resumed process rebuilds the same goal
2197 /// control state instead of inferring it from transcript prose.
2198 pub(crate) last_known_goal_state: Option<crate::session_manager::SessionGoalState>,
2199 /// FIFO of accepted typed controls not yet reconciled by GoalUpdated.
2200 /// The durable desired state lives in `last_known_goal_state`; this queue
2201 /// preserves in-process ordering and mailbox retry state only.
2202 pub(crate) pending_goal_controls: VecDeque<PendingGoalControl>,
2203 /// Metadata for the active session, cached in memory so automatic
2204 /// checkpoints never synchronously reload and parse a growing JSON file on
2205 /// the UI thread.
2206 pub(crate) current_session_metadata: Option<SessionMetadata>,
2207 /// Metadata-only registry of large tool outputs produced in this session.
2208 pub session_artifacts: Vec<ArtifactRecord>,
2209 /// Turns in this session that ended `Failed`, persisted with the session
2210 /// so the reason survives the TUI closing.
2211 pub(crate) session_turn_outcomes: Vec<crate::session_manager::SavedTurnOutcome>,
2212 /// Trust mode - allow access outside workspace
2213 pub trust_mode: bool,
2214 /// Translation mode — when enabled, the model is instructed to respond in
2215 /// the current locale and a post-hoc translation layer replaces any
2216 /// remaining English output before it reaches the user.
2217 pub translation_enabled: bool,
2218 /// Mini-window (pinned always-on-top) layout preferences, sourced from
2219 /// `[mini_window]` in config.toml at startup and mutated live by
2220 /// `/config mini_window.keep_*`. The renderer reads this instead of the
2221 /// parsed Config so runtime changes apply without a restart.
2222 pub(crate) mini_window: crate::config::MiniWindowConfig,
2223 /// What the bottom chrome shows. Sourced from `tui.status_items` in
2224 /// `~/.deepseek/config.toml` at startup; mutated live by `/statusline`.
2225 ///
2226 /// Read by [`crate::tui::ui::frame::info_segments`] for every segment of
2227 /// the metrics line, by `tideline_footer_from_app` for the posture bar's
2228 /// mode chip, and by `should_fetch_provider_balance` for the balance
2229 /// fetch. Every variant in the list paints exactly one of those; the
2230 /// items that painted nothing were retired in #5950 rather than left as
2231 /// toggles that lie.
2232 pub status_items: Vec<crate::config::StatusItem>,
2233 /// How much of the posture bar to paint (`tui.posture_bar`, #5950):
2234 /// full, compact, or hidden. Sourced from `config.toml` at startup and
2235 /// mutated live by `/config posture_bar`. `hidden` gives the row to the
2236 /// transcript; `compact` starts the bar's shed ladder past the clocks,
2237 /// counts and hints. `status_items` composes the row; this sizes it.
2238 pub posture_bar: crate::config::ChromeRowPreset,
2239 /// The same setting for the metrics line (`tui.metrics_line`, #5950).
2240 /// `compact` keeps the route, context, cost and balance and drops the
2241 /// telemetry and the help hint.
2242 pub metrics_line: crate::config::ChromeRowPreset,
2243 /// Project documentation (AGENTS.md or CLAUDE.md)
2244 #[expect(dead_code)]
2245 pub project_doc: Option<String>,
2246 /// Plan state for tracking tasks
2247 pub plan_state: SharedPlanState,
2248 /// Todo list for the canonical `work_update` progress surface.
2249 pub todos: SharedTodoList,
2250 /// Durable runtime services exposed to model-visible task/automation tools.
2251 pub runtime_services: RuntimeToolServices,
2252 /// Latest bounded coordination receipt delivered by the engine. This is
2253 /// the same typed projection returned to headless inspection; the TUI does
2254 /// not parse tool text to reconstruct it.
2255 pub coordination_detail: Option<crate::tools::subagent::CoordinationDetailProjection>,
2256 /// Last MCP manager/discovery snapshot shown in the UI.
2257 pub mcp_snapshot: Option<crate::mcp::McpManagerSnapshot>,
2258 /// True while the engine-owned MCP boot connection pass is in flight.
2259 /// Configured rows render as connecting until its snapshot lands.
2260 pub mcp_initializing: bool,
2261 /// Latest engine-owned MCP event generation applied to the UI.
2262 pub mcp_snapshot_generation: u64,
2263 /// The direct snapshot from a successful `/mcp` action supersedes any
2264 /// queued event at `mcp_snapshot_generation`, but not a later generation.
2265 pub mcp_snapshot_generation_invalidated: bool,
2266 /// Enabled servers that have not settled in the current boot pass.
2267 pub mcp_connecting: Vec<String>,
2268 /// Number of MCP servers declared in the user's config at app boot.
2269 /// Used by the footer chip (#502) so a count is visible even before
2270 /// the user runs `/mcp` for the first time. `0` hides the chip.
2271 pub mcp_configured_count: usize,
2272 /// Set after in-TUI MCP config edits because the engine caches its MCP pool.
2273 pub mcp_reload_required: bool,
2274 /// True between an accepted `/mcp` reload (or mutation that rebuilds the
2275 /// live pool) and the background pass's finished receipt, so completion
2276 /// can post exactly one summary.
2277 pub mcp_reload_in_flight: bool,
2278 /// Tool execution log
2279 pub tool_log: Vec<String>,
2280 /// Active skill to apply to next user message
2281 pub active_skill: Option<String>,
2282 /// Content-bound plugin authority carried with `active_skill`, when the
2283 /// selected skill came from a reviewed plugin bundle.
2284 pub active_skill_provenance: Option<crate::skills::SkillProvenance>,
2285 /// Cached (name, description) pairs from the skill registry.
2286 /// Populated once at startup and refreshed on install/uninstall so
2287 /// the slash menu can show skills without filesystem I/O on every keystroke.
2288 pub cached_skills: Vec<(String, String)>,
2289 /// Tool call cells by tool id (for cells already finalized in `history`).
2290 /// While a tool call is in flight inside `active_cell`, it is tracked by
2291 /// `active_tool_entries` instead and migrated here at flush time.
2292 pub tool_cells: HashMap<String, usize>,
2293 /// Full tool input/output keyed by history cell index.
2294 pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
2295 /// Linked context references keyed by the visible user history cell that
2296 /// introduced them.
2297 pub context_references_by_cell: HashMap<usize, Vec<SessionContextReference>>,
2298 /// Session-wide context references persisted with saved sessions.
2299 pub session_context_references: Vec<SessionContextReference>,
2300 /// In-flight tool/exec group for the current turn. Mutated in place as
2301 /// parallel tool calls start and complete; flushed into `history` on
2302 /// `TurnComplete`.
2303 pub active_cell: Option<ActiveCell>,
2304 /// Revision counter for `active_cell`. Combined with `active_cell.revision`
2305 /// when feeding the transcript cache so cached lines for the synthetic
2306 /// active-cell row are invalidated on every mutation.
2307 pub active_cell_revision: u64,
2308 /// Pending tool details for entries that live inside `active_cell`.
2309 /// Keyed by tool id rather than cell index because the active cell's
2310 /// virtual index can shift (orphan completions push real cells in
2311 /// between). Migrated into `tool_details_by_cell` on flush.
2312 pub active_tool_details: HashMap<String, ToolDetailRecord>,
2313 /// Completion timestamps for entries still living inside `active_cell`.
2314 /// The transcript keeps completed entries until turn flush, but the
2315 /// sidebar can use these timestamps to let settled live rows expire.
2316 pub active_tool_entry_completed_at: HashMap<usize, Instant>,
2317 /// Active exploring cell entry index (within `active_cell.entries`).
2318 /// `None` once the active cell flushes or no exploring entry exists.
2319 pub exploring_cell: Option<usize>,
2320 /// Mapping of exploring tool ids to `(entry index in active_cell, entry
2321 /// within ExploringCell)`. Used to update individual exploring entries
2322 /// when their tools complete.
2323 pub exploring_entries: HashMap<String, (usize, usize)>,
2324 /// Tool calls that should be ignored by the UI
2325 pub ignored_tool_calls: HashSet<String>,
2326 /// Last exec wait command shown (for duplicate suppression)
2327 pub last_exec_wait_command: Option<String>,
2328 /// Current streaming assistant cell
2329 pub streaming_message_index: Option<usize>,
2330 /// Provenance for append-only changes to the current streaming cell.
2331 /// Revisions are raw `history_revisions`; the widget maps them through its
2332 /// cache-key transform before handing the receipt to the transcript cache.
2333 pub(crate) streaming_source_receipt: Option<crate::tui::transcript::StreamingSourceReceipt>,
2334 /// True after a local cancel key has been handled and before the engine's
2335 /// authoritative TurnComplete arrives. Stream events already queued for
2336 /// the cancelled turn are ignored so text does not keep appearing after
2337 /// Ctrl+C/Esc returns focus to the composer.
2338 pub suppress_stream_events_until_turn_complete: bool,
2339 /// Index into `active_cell.entries` of the thinking entry currently being
2340 /// streamed. `None` when no thinking block is in flight. P2.3 routes
2341 /// thinking into the active cell so it groups visually with tool calls
2342 /// until the next assistant prose chunk flushes the group into history.
2343 pub streaming_thinking_active_entry: Option<usize>,
2344 /// Instant of the last throttled active-cell revision bump for the
2345 /// in-flight thinking stream (#1620). Reasoning chunks arrive faster than
2346 /// the eye can read, and each bump invalidates the active cell's wrap
2347 /// cache, forcing a full re-wrap. We debounce intermediate bumps to a
2348 /// time window so high-frequency thinking deltas no longer trigger a
2349 /// re-render per character. `None` means "no bump since the last
2350 /// finalize" so the first chunk of a block always renders immediately.
2351 pub thinking_revision_last_bump_at: Option<Instant>,
2352 /// Newline-gated streaming collector state.
2353 pub streaming_state: StreamingState,
2354 /// Live approximate output tokens for the current assistant stream.
2355 pub streaming_output_token_estimate: u64,
2356 /// Provider-billed prompt tokens from the most recent parent model call
2357 /// (per-step `TurnUsage`). The context meter takes the max of this and
2358 /// the local estimate — the same rule the auto-compaction trigger uses —
2359 /// so the two can never disagree about pressure (#5577). Cleared when
2360 /// compaction rewrites history, since the receipt describes the
2361 /// pre-compaction context.
2362 pub last_billed_input_tokens: Option<u32>,
2363 /// Last successful compaction, so `/context` and the inspector can name
2364 /// the path and the last-round floor instead of looking empty.
2365 pub last_compaction: Option<crate::compaction::LastCompactionSnapshot>,
2366 /// Accumulated reasoning text
2367 pub reasoning_buffer: String,
2368 /// Live reasoning header extracted from bold text
2369 pub reasoning_header: Option<String>,
2370 /// Last completed reasoning block
2371 pub last_reasoning: Option<String>,
2372 /// Tool calls captured for the pending assistant message
2373 pub pending_tool_uses: Vec<ContentBlock>,
2374 /// One-line permission receipts (`tool_id`, text) for decisions nobody
2375 /// was prompted for, held until that tool's card completes so the note
2376 /// lands directly under the card instead of splitting a running tool run.
2377 pub pending_gate_receipts: Vec<(String, String)>,
2378 /// Permission receipts for child (sub-agent) tool calls, keyed by agent
2379 /// id then `(tool_id, text)`, rendered under the matching tool card when
2380 /// that child is focused. Session-resident only.
2381 pub child_gate_receipts: std::collections::HashMap<String, Vec<(String, String)>>,
2382 /// User messages queued while a turn is running
2383 pub queued_messages: VecDeque<QueuedMessage>,
2384 /// Draft queued message being edited
2385 pub queued_draft: Option<QueuedMessage>,
2386 /// Legacy pending-steer bucket retained for session compatibility. New
2387 /// in-flight input uses Ctrl+Enter for same-turn steering and Enter for
2388 /// queued follow-ups; Esc only cancels the active turn.
2389 pub pending_steers: VecDeque<QueuedMessage>,
2390 /// Steers accepted by the steer channel but not yet seen in the engine's
2391 /// record. Rendered through the same "sending into turn" preview bucket as
2392 /// `pending_steers`; promoted to a transcript cell by
2393 /// `apply_engine_session_projection`, or queued as a follow-up by
2394 /// `TurnComplete` when the turn ended without them (#6190, #6297).
2395 pub inflight_steers: VecDeque<InflightSteer>,
2396 /// Legacy resend flag for pending steer recovery.
2397 pub submit_pending_steers_after_interrupt: bool,
2398 /// Start time for current turn
2399 pub turn_started_at: Option<Instant>,
2400 /// Most recent engine event observed for the current turn. This is
2401 /// separate from `turn_started_at` because the latter drives elapsed-time
2402 /// UI and must not be reset during long but healthy turns.
2403 pub turn_last_activity_at: Option<Instant>,
2404 /// Sum of completed turn durations for this `App` instance (#448
2405 /// follow-up). Drives the footer's `worked Nh Mm` chip so the
2406 /// label reflects actual model work, not wall-clock since launch.
2407 /// Incremented on `TurnComplete` from the elapsed time of the
2408 /// just-finished turn. Resets per launch.
2409 pub cumulative_turn_duration: std::time::Duration,
2410 /// Session metrics strip accumulators (model-call/tool timings, TTFT,
2411 /// throughput). Sourced only from engine events; see
2412 /// [`crate::tui::session_metrics`].
2413 pub session_metrics: crate::tui::session_metrics::SessionMetrics,
2414 /// DeepSeek account balance, refreshed once per turn completion.
2415 /// Shared cell updated by background fetch tasks; read lock in the UI thread.
2416 pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
2417 /// The route `balance_cell` belongs to (provider, endpoint, key
2418 /// fingerprint). A route change swaps in a fresh cell; see
2419 /// `provider_routes::balance_cell_for_route`.
2420 pub balance_route: Option<String>,
2421 /// Shared cell for async fleet-profile model-draft delivery. A background
2422 /// task fills it (model label + drafted profile or a failure reason) so
2423 /// the drafting network call never parks the event loop (#3757 review).
2424 #[allow(clippy::type_complexity)]
2425 /// Monotonic generation for model-draft requests. Bumped on each draft
2426 /// request and each setup/fleet wizard open, so a draft that lands after
2427 /// a superseding request or a wizard reopen is dropped rather than
2428 /// installed into the wrong (or a stale) wizard instance.
2429 pub draft_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
2430 #[allow(clippy::type_complexity)]
2431 pub fleet_draft_cell: std::sync::Arc<
2432 std::sync::Mutex<
2433 Option<(
2434 u64,
2435 String,
2436 // The `(provider, model)` route the operator picked when they
2437 // pressed `m` (#4093). Carried alongside the async draft so the
2438 // ratified profile keeps the picked cross-provider route even if
2439 // the model draft (which is always `provider: None`) omitted or
2440 // changed it. `None` for an `inherit` pick.
2441 Option<(String, String)>,
2442 // The reasoning tier selected when the operator pressed `m`
2443 // (#4137). `None` means inherit.
2444 Option<String>,
2445 Result<Box<crate::fleet::profile::FleetProfileDraft>, String>,
2446 )>,
2447 >,
2448 >,
2449 /// Shared cell for async constitution model-draft delivery (same pattern
2450 /// as `fleet_draft_cell`, so the drafting network call never parks the
2451 /// event loop).
2452 #[allow(clippy::type_complexity)]
2453 pub constitution_draft_cell: std::sync::Arc<
2454 std::sync::Mutex<
2455 Option<(
2456 u64,
2457 String,
2458 codewhale_localization::Locale,
2459 Result<Box<codewhale_config::UserConstitution>, String>,
2460 )>,
2461 >,
2462 >,
2463 /// Discovery, registration and the browser callback all run in the
2464 /// background. Esc or dropping the app cancels the entire operation.
2465 pub(crate) mcp_login: Option<PendingMcpLogin>,
2466 /// `/mcp retry` requests still waiting on the engine, one per server.
2467 pub(crate) mcp_retries: Vec<PendingMcpRetry>,
2468 /// Shared cell for async prompt suggestion delivery from background task.
2469 pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
2470 /// Tracks whether the initial balance fetch has been attempted for this session.
2471 pub balance_initiated: bool,
2472 /// Timestamp of the last balance fetch, used to debounce rapid requests.
2473 pub last_balance_fetch: Option<std::time::Instant>,
2474 /// Current runtime turn id (if known).
2475 pub runtime_turn_id: Option<String>,
2476 /// Current runtime turn status (if known).
2477 pub runtime_turn_status: Option<String>,
2478 /// Monotonic turn counter for stable user-facing labels (#3030).
2479 /// Incremented each time a new turn starts; displayed as "Turn N".
2480 pub turn_counter: u64,
2481 /// When the UI accepted a user message but has not observed `TurnStarted` yet.
2482 pub dispatch_started_at: Option<Instant>,
2483
2484 /// Cached git context snapshot for the footer.
2485 pub workspace_context: Option<String>,
2486 /// Cached linked-worktree identity, refreshed with the branch off the draw path.
2487 pub workspace_is_linked_worktree: bool,
2488 /// Shared cell for async git context updates (#399 S1).
2489 pub workspace_context_cell: std::sync::Arc<
2490 std::sync::Mutex<Option<crate::tui::workspace_context::WorkspaceContextSnapshot>>,
2491 >,
2492 /// Timestamp for cached workspace context.
2493 pub workspace_context_refreshed_at: Option<Instant>,
2494 /// Cached size of the memory file, formatted for the Session sidebar.
2495 ///
2496 /// Rendered every frame the Session/Context panel is visible, so the
2497 /// `stat` behind it is refreshed on the workspace-context TTL tick
2498 /// instead of inside the draw closure (#3908) — tens of ms per frame on
2499 /// NFS/SSHFS/cloud-synced homes otherwise.
2500 pub memory_size_hint: Option<String>,
2501 /// The workspace notes (`/note`), refreshed with the workspace context
2502 /// off the render path; the dock's NOTES view lists them (#6565).
2503 pub workspace_notes: Vec<String>,
2504 /// Cached background tasks for sidebar rendering.
2505 pub task_panel: Vec<TaskPanelEntry>,
2506 pub task_panel_session_id: Option<String>,
2507 pub task_panel_unavailable: bool,
2508 /// Live scheduled-work projection for the activity band
2509 /// (AUTOMATION-VISIBILITY-SPEC §2.1), refreshed on the task-panel cadence
2510 /// by `refresh_automation_panel`. The band reads it;
2511 /// `background_indicator` never learns about automations.
2512 pub automation_panel: crate::tui::automation_panel::AutomationPanelState,
2513 /// The automation store scan in flight for `automation_panel`, if any.
2514 /// The scan reads every definition and run file, so it runs on a
2515 /// blocking thread and the tick folds it once it has finished — the
2516 /// async UI loop never parks behind the automation store's disk.
2517 pub automation_scan:
2518 Option<tokio::task::JoinHandle<crate::tui::automation_panel::AutomationScan>>,
2519 /// Session-local quieting and command detectors for event-driven tips.
2520 pub behavioral_tips: crate::tui::behavioral_tips::BehavioralTipState,
2521 /// Footer-hint use counts, hydrated from `Settings` at startup and
2522 /// bumped by `App::note_footer_hint_used`. The posture bar reads this
2523 /// every frame, so the counts live here rather than behind a
2524 /// settings-file read.
2525 pub footer_hint_uses: std::collections::BTreeMap<String, u8>,
2526 /// Every workflow run this session is showing, in start order, one state
2527 /// per run id (#4121). The workbar under the composer paints one row per
2528 /// run; the transcript gets only each run's start and finish lines.
2529 /// Settled runs stay until a later turn starts with nothing still running.
2530 pub workflow_runs: Vec<crate::tui::widgets::workflow_panel::WorkflowPanel>,
2531 /// Wall-clock time when this TUI session started. Used by the Work
2532 /// sidebar projection to hide completed durable tasks that finished
2533 /// before the current session (bug #1913).
2534 pub session_started_at: chrono::DateTime<chrono::Utc>,
2535 /// Whether the UI needs to be redrawn.
2536 pub needs_redraw: bool,
2537 /// A fleet mutation (`/fleet add|remove`, ⇧F, auto-enroll) landed on
2538 /// disk since the engine last received its roster. The event loop
2539 /// flushes it through `Op::SetFleetRoster` (`sync_fleet_roster`).
2540 pub fleet_roster_stale: bool,
2541 /// When true, the next draw will be a full repaint (terminal clear +
2542 /// all cells redrawn) instead of a ratatui incremental diff. Used by
2543 /// theme switches where the diff engine may miss color-only changes
2544 /// in sidebar cells that were previously rendered with palette constants.
2545 pub force_next_full_repaint: bool,
2546 /// When the current thinking block started (for duration tracking).
2547 pub thinking_started_at: Option<Instant>,
2548 /// Whether context compaction is currently in progress.
2549 pub is_compacting: bool,
2550 /// Typed identity retained from CompactionStarted until its matching
2551 /// CompactionCompleted/CompactionFailed event.
2552 pub(crate) active_compaction: Option<ActiveCompaction>,
2553 /// A manual compaction op accepted by the UI but waiting for the engine to
2554 /// finish the active turn and emit CompactionStarted.
2555 pub(crate) manual_compaction_queued: bool,
2556 /// Stable identity allocated before the manual request enters the engine
2557 /// mailbox, retained so Ctrl+C/Esc can cancel that exact queued pass.
2558 pub(crate) manual_compaction_id: Option<String>,
2559 /// A manual compaction request that found the bounded engine mailbox full.
2560 /// The event loop retries the send once a slot frees; any compaction that
2561 /// starts or settles in the meantime supersedes it. Inner value is the
2562 /// requested focus.
2563 pub(crate) deferred_manual_compaction: Option<Option<String>>,
2564 /// Whether context purge is currently in progress.
2565 pub is_purging: bool,
2566 /// Set when the user scrolls up/down during a streaming turn so subsequent
2567 /// streamed chunks don't yank the view back to the live tail. Cleared
2568 /// when the user explicitly returns to bottom or the turn completes.
2569 pub user_scrolled_during_stream: bool,
2570 /// Timestamp of the last user message send (for brief visual feedback).
2571 pub last_send_at: Option<Instant>,
2572 /// Most recent user prompt accepted for an active engine turn. Ctrl+C can
2573 /// restore this into an empty composer after cancelling that turn.
2574 pub last_submitted_prompt: Option<String>,
2575 /// The dispatched message of the turn in flight, until that turn ends.
2576 /// A credential rejection the engine marks unsent hands it back (#6566).
2577 pub unanswered_submission: Option<UnansweredSubmission>,
2578 /// Startup prompt should be submitted automatically after the engine is ready.
2579 pub auto_submit_initial_input: bool,
2580 /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has
2581 /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits
2582 /// the app, while expiry silently re-arms the prompt for next time.
2583 /// Stays `None` while a turn is in flight or a modal/picker is open so
2584 /// Ctrl+C keeps its current "interrupt this turn" semantics in those
2585 /// states. See [`App::arm_quit`] / [`App::quit_is_armed`].
2586 pub quit_armed_until: Option<Instant>,
2587
2588 // === Prefix-Cache Stability Tracking ===
2589 /// Number of times the prefix (system prompt + tool specs) has changed.
2590 pub prefix_change_count: u64,
2591 /// Total number of prefix stability checks performed.
2592 pub prefix_checks_total: u64,
2593 /// Current prefix stability percentage, if known.
2594 pub prefix_stability_pct: Option<u32>,
2595 /// Description of the last prefix change, if any.
2596 pub last_prefix_change_desc: Option<String>,
2597 /// Current pinned prefix combined hash (SHA-256, 64 hex chars).
2598 /// Updated per-turn via PrefixCacheChange events; surfaced by
2599 /// `/cache stats` for cache-hit debugging.
2600 pub last_pinned_prefix_hash: Option<String>,
2601 /// Why the current KV-cache prefix pin exists (`initial`/`resume`/`change:*`).
2602 pub prefix_pin_reason: Option<String>,
2603 /// Explanation of the most recent expected cache miss.
2604 pub prefix_last_miss_reason: Option<String>,
2605 /// Undeclared prefix drifts this session (should stay 0 after the fix).
2606 pub prefix_drift_count: u64,
2607 /// `<context_update>` snapshots appended this session.
2608 pub prefix_context_updates: u64,
2609
2610 // === Transcript filtering (#397) ===
2611 /// Transcript cells the user has collapsed (hidden from view).
2612 /// Stores **original** virtual cell indices (pre-filtering).
2613 pub collapsed_cells: HashSet<usize>,
2614 /// Explicit expand/collapse intents the user has recorded for thinking
2615 /// cells, keyed by **original** virtual cell index. Set by Space when the
2616 /// composer is empty and the cursor is on a thinking cell.
2617 ///
2618 /// An absent index means the user has not touched that cell, so the
2619 /// display preferences decide it. A present index is absolute, so
2620 /// changing `verbose` or `thinking_default_expanded` afterwards leaves
2621 /// the user's own choice alone (#5847).
2622 pub thinking_folds: HashMap<usize, ThinkingFold>,
2623 /// Mapping from filtered cell index → original virtual index.
2624 /// Populated during `ChatWidget::new` by filtering out collapsed cells.
2625 /// Used by `build_context_menu_entries` to convert line-meta indices
2626 /// back to original indices for the `HideCell` / `ShowCell` actions.
2627 pub collapsed_cell_map: Vec<usize>,
2628
2629 /// Whether `/edit` has loaded the last user message into the composer and
2630 /// the next submit should replace (not append to) the last exchange.
2631 pub edit_in_progress: bool,
2632
2633 /// Whether LSP diagnostics are currently enabled. Mirrors the config file
2634 /// `[lsp].enabled` setting. Toggled at runtime via `/lsp on|off`.
2635 pub lsp_enabled: bool,
2636 /// Current-turn LSP repair-loop summary for Ctrl-O Turn Inspector (#4107).
2637 pub lsp_repair: LspRepairState,
2638 /// Derived title for the current session shown in the composer border.
2639 /// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded.
2640 pub session_title: Option<String>,
2641
2642 /// User-configured tab/window title for the current session, shown as
2643 /// `[title] …` in front of the terminal window title. Set with the
2644 /// `/title` command and persisted on the saved session; distinct from
2645 /// [`session_title`](Self::session_title), which is the session *name*
2646 /// shown in the composer border and session picker.
2647 pub window_title: Option<String>,
2648 /// Default tab/window title from the `title` config key (or a profile
2649 /// overlay). Used when the current session has no explicit
2650 /// [`window_title`](Self::window_title).
2651 pub title_default: Option<String>,
2652
2653 /// Post-turn receipt rendered as transient composer chrome.
2654 /// Set when a turn completes; cleared when a new turn starts or after expiry.
2655 pub receipt_text: Option<String>,
2656 pub receipt_started_at: Option<Instant>,
2657 /// Tool evidence collected during the current turn for the receipt.
2658 pub tool_evidence: Vec<ToolEvidence>,
2659 }
2660
2661 pub(crate) struct ToolRunCache {
2662 /// Bumped each time the projection below is rebuilt, so anything derived
2663 /// from it (the collapsed-row mapping) can tell it is stale without
2664 /// re-deriving the key.
2665 pub(crate) generation: u64,
2666 pub(crate) filtered: FilteredProjection,
2667 pub(crate) history_version: u64,
2668 pub(crate) active_cell_revision: u64,
2669 pub(crate) active_len: usize,
2670 pub(crate) threshold: usize,
2671 pub(crate) mode: ToolCollapseMode,
2672 pub(crate) calm_mode: bool,
2673 #[cfg(test)]
2674 pub(crate) projection_builds: usize,
2675 pub(crate) history_len: usize,
2676 pub(crate) expanded_runs: HashSet<usize>,
2677 pub(crate) summaries: HashMap<usize, (HistoryCell, u64)>,
2678 pub(crate) hidden_indices: HashSet<usize>,
2679 pub(crate) superseded_todos: HashSet<usize>,
2680 }
2681
2682 /// The collapsed transcript path's rendered-row -> original-index mapping.
2683 ///
2684 /// Which rows survive filtering depends only on the tool-run projection and
2685 /// the user's hidden cells, never on cell revisions, so it is computed once
2686 /// per change of either instead of once per frame with a hash lookup per
2687 /// history cell (#6652). Revisions are still read fresh every frame.
2688 #[derive(Default)]
2689 pub(crate) struct FilteredProjection {
2690 /// `ToolRunCache::generation` this mapping was built for.
2691 built_for: Option<u64>,
2692 collapsed_cells: HashSet<usize>,
2693 /// Rendered position -> original virtual index.
2694 pub(crate) original: Vec<usize>,
2695 /// Rendered positions that show a collapsed-run summary instead of the
2696 /// cell at their original index.
2697 pub(crate) summary_slots: Vec<usize>,
2698 }
2699
2700 impl ToolRunCache {
2701 /// Refresh [`FilteredProjection`] unless it already matches the current
2702 /// projection and `collapsed_cells`. `rows` is committed plus active.
2703 pub(crate) fn refresh_filtered(&mut self, rows: usize, collapsed_cells: &HashSet<usize>) {
2704 if self.filtered.built_for == Some(self.generation)
2705 && self.filtered.collapsed_cells == *collapsed_cells
2706 {
2707 return;
2708 }
2709 self.filtered.original.clear();
2710 self.filtered.summary_slots.clear();
2711 for index in 0..rows {
2712 if self.superseded_todos.contains(&index)
2713 || collapsed_cells.contains(&index)
2714 || self.hidden_indices.contains(&index)
2715 {
2716 continue;
2717 }
2718 if self.summaries.contains_key(&index) {
2719 self.filtered
2720 .summary_slots
2721 .push(self.filtered.original.len());
2722 }
2723 self.filtered.original.push(index);
2724 }
2725 self.filtered.collapsed_cells.clone_from(collapsed_cells);
2726 self.filtered.built_for = Some(self.generation);
2727 }
2728 }
2729
2730 impl Default for ToolRunCache {
2731 fn default() -> Self {
2732 Self {
2733 generation: 0,
2734 filtered: FilteredProjection::default(),
2735 history_version: u64::MAX,
2736 active_cell_revision: u64::MAX,
2737 active_len: usize::MAX,
2738 threshold: usize::MAX,
2739 mode: ToolCollapseMode::Expanded,
2740 calm_mode: false,
2741 #[cfg(test)]
2742 projection_builds: 0,
2743 history_len: usize::MAX,
2744 expanded_runs: HashSet::new(),
2745 summaries: HashMap::new(),
2746 hidden_indices: HashSet::new(),
2747 superseded_todos: HashSet::new(),
2748 }
2749 }
2750 }
2751
2752 // === Deref to ComposerState for backward compat ===
2753
2754 impl std::ops::Deref for App {
2755 type Target = ComposerState;
2756 fn deref(&self) -> &Self::Target {
2757 &self.composer
2758 }
2759 }
2760
2761 impl std::ops::DerefMut for App {
2762 fn deref_mut(&mut self) -> &mut Self::Target {
2763 &mut self.composer
2764 }
2765 }
2766
2767 // === App State ===
2768
2769 pub(crate) fn default_composer_arrows_scroll(use_mouse_capture: bool) -> bool {
2770 default_composer_arrows_scroll_for_platform(use_mouse_capture, cfg!(windows))
2771 }
2772
2773 fn default_composer_arrows_scroll_for_platform(use_mouse_capture: bool, _is_windows: bool) -> bool {
2774 !use_mouse_capture
2775 }
2776
2777 impl App {
2778 /// A retained roster remains readable only in its owning conversation.
2779 pub(crate) fn current_agent_roster(&self) -> &[crate::agent_roster::AgentRosterRow] {
2780 if self
2781 .current_session_id
2782 .as_deref()
2783 .is_some_and(|session_id| {
2784 !session_id.is_empty()
2785 && self.agent_roster_session_id.as_deref() == Some(session_id)
2786 })
2787 {
2788 &self.agent_roster
2789 } else {
2790 &[]
2791 }
2792 }
2793
2794 /// Who owns the keyboard right now.
2795 ///
2796 /// The single derivation of [`Focus`], mirroring the order in which the
2797 /// event loop actually offers a key to each surface. Shell bindings ask
2798 /// this; only genuine composer *editing* keys may ask whether the
2799 /// composer has text.
2800 #[must_use]
2801 pub fn focus(&self) -> Focus {
2802 if self.redaction_gate && self.onboarding == OnboardingState::None {
2803 return Focus::RedactionGate;
2804 }
2805 if let Some(kind) = self.view_stack.top_kind() {
2806 return Focus::Modal(kind);
2807 }
2808 if self.onboarding != OnboardingState::None {
2809 return Focus::Onboarding;
2810 }
2811 if self.launch.visible {
2812 return Focus::Launch;
2813 }
2814 if self.work_surface.focused {
2815 return Focus::Panel;
2816 }
2817 Focus::Composer
2818 }
2819
2820 /// Whether the live terminal is on the alternate screen buffer.
2821 ///
2822 /// Derived from [`App::screen_mode`] rather than stored, so every
2823 /// pause/resume/teardown site reads the mode the terminal is actually in
2824 /// after a `/fullscreen` or `/inline` switch.
2825 #[must_use]
2826 pub const fn use_alt_screen(&self) -> bool {
2827 self.screen_mode.uses_alt_screen()
2828 }
2829
2830 /// Persist the pending session route as the explicit choice (`/fleet save`,
2831 /// `/fleet save-as`, `/model save-default`). Returns the receipt
2832 /// message naming the exact file written — or an error message when the
2833 /// write failed. Nothing is ever written without this explicit call.
2834 pub fn apply_route_save_choice(
2835 &mut self,
2836 choice: crate::tui::views::route_save_prompt::RouteSaveChoice,
2837 ) -> String {
2838 use crate::fleet::store::{FleetFile, FleetOperator, save_fleet, set_selected};
2839 use crate::tui::views::route_save_prompt::RouteSaveChoice;
2840 let Some(pending) = self.pending_route_save.take() else {
2841 return "No pending route change to save.".to_string();
2842 };
2843 let route = format!("{}/{}", pending.provider_identity, pending.model);
2844 match choice {
2845 RouteSaveChoice::UpdateFleet => {
2846 let Some((name, scope)) = pending.fleet.clone() else {
2847 return "Nothing to update — no team is selected. Use /fleet save-as to \
2848 save this route as a new team."
2849 .to_string();
2850 };
2851 match crate::fleet::store::load_fleet_in_scope(&name, scope, &self.workspace) {
2852 Ok((mut fleet, _source_path)) => {
2853 fleet.operator = Some(FleetOperator {
2854 provider: pending.provider_identity.clone(),
2855 model: pending.model.clone(),
2856 reasoning: fleet.operator.as_ref().and_then(|op| op.reasoning.clone()),
2857 });
2858 match save_fleet(&fleet, scope, &self.workspace) {
2859 Ok(path) => format!(
2860 "Team `{}` now runs on {route} — wrote {}",
2861 fleet.name,
2862 path.display()
2863 ),
2864 Err(err) => format!("Team update failed: {err}"),
2865 }
2866 }
2867 Err(err) => format!(
2868 "Team update failed: {err} — the saved team may have moved. Use \
2869 /fleet save-as to persist the route."
2870 ),
2871 }
2872 }
2873 RouteSaveChoice::SaveAsNewFleet => {
2874 let display = format!(
2875 "{} {}",
2876 crate::config::ProviderKind::parse(&pending.provider_identity)
2877 .map(|p| p.provider().display_name().to_string())
2878 .unwrap_or_else(|| pending.provider_identity.clone()),
2879 pending.model
2880 );
2881 let Ok(mut fleet) = FleetFile::new(
2882 display.clone(),
2883 Some("Saved from a session route choice.".to_string()),
2884 ) else {
2885 return "Could not create the team.".to_string();
2886 };
2887 fleet.operator = Some(FleetOperator {
2888 provider: pending.provider_identity.clone(),
2889 model: pending.model.clone(),
2890 reasoning: None,
2891 });
2892 match save_fleet(
2893 &fleet,
2894 crate::fleet::store::FleetScope::Personal,
2895 &self.workspace,
2896 ) {
2897 Ok(path) => {
2898 let selected_note = match set_selected(
2899 &display,
2900 crate::fleet::store::FleetScope::Personal,
2901 &self.workspace,
2902 ) {
2903 Ok(sel_path) => format!(
2904 " — selected as your user-global default; wrote {}",
2905 sel_path.display()
2906 ),
2907 Err(err) => format!(" — selection failed: {err}"),
2908 };
2909 format!(
2910 "Saved route {route} as new team `{}` — wrote {}{selected_note}",
2911 display,
2912 path.display()
2913 )
2914 }
2915 Err(err) => format!("Save failed: {err}"),
2916 }
2917 }
2918 RouteSaveChoice::SaveAsDefault => {
2919 let active_model = if self.auto_model { "auto" } else { &self.model };
2920 if (pending.provider_identity != self.provider_identity_for_persistence()
2921 && Some(pending.provider_identity.as_str())
2922 != self.provider_id_for_persistence())
2923 || pending.model != active_model
2924 {
2925 return "Save failed: the pending provider/model route is no longer active."
2926 .to_string();
2927 }
2928 let identity = match self.admitted_provider_identity() {
2929 Ok(identity) => identity,
2930 Err(error) => return format!("Save failed: {error}"),
2931 };
2932 persist_route_as_startup_default(identity, &pending.model)
2933 }
2934 RouteSaveChoice::SessionOnly => {
2935 format!("Model {route} kept for this session only — nothing was written.")
2936 }
2937 }
2938 }
2939
2940 /// Persist the route this session is *actually* running as the startup
2941 /// default.
2942 ///
2943 /// This reads the live route rather than [`Self::pending_route_save`] on
2944 /// purpose. The pending record is bookkeeping for the save *prompt*, and it
2945 /// is written by several different paths (same-provider apply, cross-
2946 /// provider `switch_provider`, `/model`). Cross-checking it before an
2947 /// explicit "make this my default" action meant that any ordering
2948 /// disagreement dropped the write with no error shown — the user saw a
2949 /// normal "Model: x → y" line and reasonably assumed it had stuck, then the
2950 /// next launch reopened the old route. An explicit request now always
2951 /// reports what it did.
2952 pub fn save_live_route_as_startup_default(&mut self) -> String {
2953 match self.try_save_live_route_as_startup_default() {
2954 Ok(receipt) => receipt,
2955 Err(err) => format!("Save failed: {err}"),
2956 }
2957 }
2958
2959 /// Persist the live route with a typed failure for onboarding, whose next
2960 /// transition depends on knowing that the restart route actually landed.
2961 pub(crate) fn try_save_live_route_as_startup_default(&mut self) -> anyhow::Result<String> {
2962 let provider_identity = self.provider_identity_for_persistence().to_string();
2963 let model = if self.auto_model {
2964 "auto".to_string()
2965 } else {
2966 self.model.clone()
2967 };
2968 try_persist_route_as_startup_default(
2969 self.admitted_provider_identity()
2970 .map_err(anyhow::Error::msg)?,
2971 &model,
2972 )?;
2973 // Resolve the prompt only after the write lands. If persistence fails,
2974 // keep the retry available instead of discarding the operator's route.
2975 self.pending_route_save = None;
2976 Ok(format!(
2977 "Remembered {provider_identity}/{model} as the startup default (config.toml)."
2978 ))
2979 }
2980
2981 /// Record that the live session route changed to `provider_identity` /
2982 /// `model`. The change is temporary until the user explicitly chooses how
2983 /// to save it; nothing is written here.
2984 pub fn note_session_route_change(&mut self, provider_identity: &str, model: &str) {
2985 let fleet =
2986 crate::fleet::store::selected_fleet(&self.workspace).map(|sel| (sel.name, sel.scope));
2987 self.pending_route_save = Some(PendingRouteSave {
2988 provider_identity: provider_identity.to_string(),
2989 model: model.to_string(),
2990 fleet,
2991 });
2992 }
2993
2994 /// One truthful chip for cumulative session cost surfaces.
2995 ///
2996 /// Session history wins over the *current* route: switching to an OAuth or
2997 /// local route must not hide spend already accrued on a metered route, and
2998 /// an unpriced turn turns a displayed amount into a subtotal rather than a
2999 /// complete total.
3000 #[must_use]
3001 pub fn cumulative_usage_chip(&self) -> crate::route_billing::UsageChip {
3002 use crate::pricing::UnpricedReason;
3003 let displayed = self.displayed_session_cost_for_currency(self.cost_currency);
3004 let (priced, unpriced) = match self.cost_display_currency(self.cost_currency) {
3005 CostCurrency::Usd => (
3006 self.session.cost_priced_turns,
3007 self.session.cost_unpriced_turns,
3008 ),
3009 CostCurrency::Cny => (
3010 self.session.cost_cny_priced_turns,
3011 self.session.cost_cny_unpriced_turns,
3012 ),
3013 };
3014 let saved_reasons = match self.cost_display_currency(self.cost_currency) {
3015 CostCurrency::Usd => &self.session.cost_unpriced_reasons,
3016 CostCurrency::Cny => &self.session.cost_cny_unpriced_reasons,
3017 };
3018 let mut reasons: Vec<_> = saved_reasons
3019 .iter()
3020 .map(|reason| UnpricedReason::from_label(reason))
3021 .collect();
3022 if (self.session.cost_coverage_unknown_legacy || reasons.is_empty())
3023 && !reasons.contains(&UnpricedReason::UnrecordedCoverage)
3024 {
3025 reasons.push(UnpricedReason::UnrecordedCoverage);
3026 }
3027 if self.session.cost_coverage_unknown_legacy {
3028 return if displayed.is_finite() && displayed > 0.0 {
3029 crate::route_billing::UsageChip::PricedSubtotal {
3030 amount: self.format_cost_amount(displayed),
3031 legacy: true,
3032 reasons,
3033 }
3034 } else {
3035 crate::route_billing::UsageChip::Unknown(reasons)
3036 };
3037 }
3038 if unpriced > 0 {
3039 return if displayed.is_finite() && displayed > 0.0 {
3040 crate::route_billing::UsageChip::PricedSubtotal {
3041 amount: self.format_cost_amount(displayed),
3042 legacy: false,
3043 reasons,
3044 }
3045 } else {
3046 crate::route_billing::UsageChip::Unknown(reasons)
3047 };
3048 }
3049 if priced > 0 {
3050 return if displayed.is_finite() && displayed > 0.0 {
3051 crate::route_billing::UsageChip::Money(self.format_cost_amount(displayed))
3052 } else {
3053 crate::route_billing::UsageChip::Hidden
3054 };
3055 }
3056 crate::route_billing::usage_chip(
3057 self.billing_presentation,
3058 self.api_provider,
3059 &self.model,
3060 displayed,
3061 self.cost_display_currency(self.cost_currency),
3062 None,
3063 )
3064 }
3065
3066 /// Record that the session is now using `provider` / `model`, so the
3067 /// picker's recent section reflects it before any session is saved.
3068 pub fn note_route_used(&mut self, provider: &str, model: &str) {
3069 if let Ok(mut usage) = self.route_usage.write() {
3070 usage.record(provider, model, chrono::Utc::now());
3071 }
3072 }
3073
3074 /// Advance and return the model-draft generation. Call when a draft is
3075 /// requested or a setup/fleet wizard opens; a spawned draft that captured
3076 /// an older generation is dropped on delivery.
3077 pub fn next_draft_gen(&self) -> u64 {
3078 self.draft_gen
3079 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
3080 + 1
3081 }
3082
3083 /// The current model-draft generation (delivery compares against this).
3084 #[must_use]
3085 pub fn current_draft_gen(&self) -> u64 {
3086 self.draft_gen.load(std::sync::atomic::Ordering::SeqCst)
3087 }
3088
3089 /// Cap on the session turn-cache history. Holds enough turns to debug a long
3090 /// session without being so large the on-screen `/cache` table wraps.
3091 pub const TURN_CACHE_HISTORY_CAP: usize = 50;
3092
3093 /// Append a per-turn cache-telemetry record, trimming the oldest entry once
3094 /// the ring exceeds [`Self::TURN_CACHE_HISTORY_CAP`].
3095 pub fn push_turn_cache_record(&mut self, record: TurnCacheRecord) {
3096 self.session.turn_cache_history.push_back(record);
3097 while self.session.turn_cache_history.len() > Self::TURN_CACHE_HISTORY_CAP {
3098 self.session.turn_cache_history.pop_front();
3099 }
3100 }
3101
3102 pub(crate) fn clear_model_scoped_telemetry(&mut self) {
3103 self.session.last_prompt_tokens = None;
3104 self.session.last_completion_tokens = None;
3105 self.session.last_prompt_cache_hit_tokens = None;
3106 self.session.last_prompt_cache_miss_tokens = None;
3107 self.session.last_reasoning_replay_tokens = None;
3108 self.session.turn_cache_history.clear();
3109 self.pending_turn_route = None;
3110 self.pending_auto_route_receipt = None;
3111 self.active_turn = None;
3112 self.last_effective_model = None;
3113 self.last_effective_provider = None;
3114 self.last_effective_provider_identity = None;
3115 self.last_auto_route_receipt = None;
3116 self.last_pinned_prefix_hash = None;
3117 self.prefix_pin_reason = None;
3118 self.prefix_last_miss_reason = None;
3119 self.prefix_drift_count = 0;
3120 self.prefix_context_updates = 0;
3121 }
3122
3123 /// Invalidate facts that were accepted under the previous reasoning
3124 /// request.
3125 ///
3126 /// A fixed model keeps the same concrete route when its reasoning tier
3127 /// changes, so only its effective-reasoning receipt becomes stale. Under
3128 /// Auto, reasoning is one of the classifier inputs; the previous concrete
3129 /// provider/model route therefore cannot be replayed or displayed as the
3130 /// route for the new request.
3131 pub(crate) fn invalidate_route_receipts_for_reasoning_change(&mut self) {
3132 self.last_effective_reasoning_effort = None;
3133 if self.auto_model {
3134 self.last_effective_model = None;
3135 self.last_effective_provider = None;
3136 self.last_effective_provider_identity = None;
3137 self.last_auto_route_receipt = None;
3138 }
3139 }
3140
3141 pub fn tr(&self, id: MessageId) -> Cow<'static, str> {
3142 tr(self.ui_locale, id)
3143 }
3144
3145 pub(crate) fn discover_cached_skills(
3146 workspace: &std::path::Path,
3147 skills_dir: &std::path::Path,
3148 discovery_mode: crate::skills::SkillDiscoveryMode,
3149 plugins: &crate::plugins::PluginRegistry,
3150 ) -> Vec<(String, String)> {
3151 crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
3152 workspace,
3153 skills_dir,
3154 discovery_mode,
3155 Some(plugins),
3156 )
3157 .into_enabled()
3158 .list()
3159 .iter()
3160 .filter(|s| s.invocation.user_invocable())
3161 .map(|s| (s.name.clone(), s.user_menu_description()))
3162 .collect()
3163 }
3164
3165 pub(crate) fn extension_plugin_view(&self) -> std::sync::Arc<crate::plugins::PluginRegistry> {
3166 crate::extension_host::caller_view(
3167 &self.workspace,
3168 self.current_session_id.as_deref(),
3169 self.agent_focus
3170 .as_ref()
3171 .map(|focus| focus.agent_id.as_str()),
3172 )
3173 .unwrap_or_else(|| std::sync::Arc::clone(&self.plugin_registry))
3174 }
3175
3176 pub fn refresh_skill_cache(&mut self) {
3177 crate::skills::clear_skill_discovery_cache();
3178 let skills_dir = self.skills_dir.clone();
3179 let cached_skills = Self::discover_cached_skills(
3180 &self.workspace,
3181 &skills_dir,
3182 self.skills_discovery_mode,
3183 self.extension_plugin_view().as_ref(),
3184 );
3185 self.install_skill_cache(cached_skills);
3186 }
3187
3188 pub(crate) fn skill_cache_scope(&self, epoch: u64) -> SkillCacheScope {
3189 SkillCacheScope {
3190 epoch,
3191 workspace: self.workspace.clone(),
3192 skills_dir: self.skills_dir.clone(),
3193 mode: self.skills_discovery_mode,
3194 plugins: self.extension_plugin_view(),
3195 }
3196 }
3197
3198 pub(crate) fn install_skill_cache_if_current(
3199 &mut self,
3200 scope: &SkillCacheScope,
3201 epoch: u64,
3202 cached_skills: Vec<(String, String)>,
3203 ) -> bool {
3204 if scope.epoch != epoch
3205 || scope.workspace != self.workspace
3206 || scope.skills_dir != self.skills_dir
3207 || scope.mode != self.skills_discovery_mode
3208 || !std::sync::Arc::ptr_eq(&scope.plugins, &self.extension_plugin_view())
3209 {
3210 return false;
3211 }
3212 self.install_skill_cache(cached_skills);
3213 self.needs_redraw = true;
3214 true
3215 }
3216
3217 pub(crate) fn install_skill_cache(&mut self, cached_skills: Vec<(String, String)>) {
3218 self.hotbar_actions.replace_skills(&cached_skills);
3219 self.cached_skills = cached_skills;
3220 }
3221
3222 /// Whether the onboarding provider picker should focus the saved route.
3223 /// A fresh home can already name a route in config. Only an unconfigured
3224 /// new user has the built-in default rather than a route to recover.
3225 pub(crate) fn onboarding_recovers_configured_route(&self) -> bool {
3226 self.onboarding_missing_key_recovery
3227 && (self.startup_route_configured || !self.onboarding_had_provider_step)
3228 }
3229
3230 pub fn finish_onboarding_without_feature_intro(&mut self) {
3231 self.onboarding = OnboardingState::None;
3232 if let Err(err) = crate::tui::onboarding::mark_onboarded() {
3233 self.status_message = Some(format!("Failed to mark onboarding: {err}"));
3234 }
3235 self.needs_redraw = true;
3236 }
3237
3238 /// Show the one-time Fleet intro as a status line, the first time the
3239 /// user opens `/fleet` or enters Operate — never as a first-run push.
3240 /// It inserts no transcript message: a synthetic history cell would hide
3241 /// the empty launch surface before the user sends anything.
3242 pub fn maybe_show_feature_intro(&mut self) {
3243 if self.onboarding != OnboardingState::None {
3244 return;
3245 }
3246 // Never claim "setup is ready" when auth is still missing — e.g.
3247 // `--skip-onboarding` with no API key (#3985). Leave the flag unset so
3248 // the tip can appear after the user finishes provider setup.
3249 if self.onboarding_needs_api_key {
3250 return;
3251 }
3252 // One transaction: the "already shown?" read and the flag write must not
3253 // straddle another writer's whole-file save.
3254 let write = Settings::transact_opt(|settings| {
3255 if settings.feature_intro_shown {
3256 return Ok(None);
3257 }
3258 settings.feature_intro_shown = true;
3259 Ok(Some(()))
3260 });
3261 match write {
3262 Ok(None) => return,
3263 Ok(Some(())) => {}
3264 Err(err) => {
3265 self.status_message = Some(format!("Failed to save feature-intro flag: {err}"));
3266 // Still show the nudge; the flag write may simply retry next launch.
3267 }
3268 }
3269 self.status_message = Some(self.tr(MessageId::FleetReadyNotice).into_owned());
3270 self.needs_redraw = true;
3271 }
3272
3273 /// Apply a locale tag selected from the onboarding language picker (#566).
3274 /// Persists the value to settings.toml and immediately
3275 /// re-resolves `ui_locale` so the rest of onboarding renders in the new
3276 /// language. `App` doesn't keep `Settings` resident — it loads on entry
3277 /// and rewrites on exit, mirroring the pattern used by the `/config`
3278 /// surface.
3279 pub fn set_locale_from_onboarding(&mut self, tag: &str) -> anyhow::Result<()> {
3280 let locale = Settings::transact(|settings| {
3281 settings.set("locale", tag)?;
3282 Ok(settings.locale.clone())
3283 })?;
3284 self.ui_locale = codewhale_localization::resolve_locale(&locale);
3285 self.needs_redraw = true;
3286 Ok(())
3287 }
3288
3289 /// Locale tag currently persisted in settings.toml (or
3290 /// `"auto"` when no settings file exists). Used by the onboarding
3291 /// language picker to highlight the current selection without `App`
3292 /// having to keep `Settings` resident.
3293 pub fn current_locale_tag(&self) -> String {
3294 Settings::load()
3295 .map(|s| s.locale)
3296 .unwrap_or_else(|_| "auto".to_string())
3297 }
3298
3299 pub fn set_mode(&mut self, mode: AppMode) -> bool {
3300 let previous_mode = self.mode;
3301 if previous_mode == mode && !self.yolo {
3302 return false;
3303 }
3304
3305 self.mode = mode;
3306 // Mode chip lives in the header — skip redundant status/toast copy.
3307
3308 // Mode cycling is untangled from permission policy (#3386). The user
3309 // only edits the durable permission surface while in Agent mode, so
3310 // refresh the baseline from the live mirrors whenever we leave Agent —
3311 // before any transient Plan/YOLO policy overwrites them. This subsumes
3312 // the old per-mode `YoloRestoreState`/`PlanRestoreState` snapshots:
3313 // cross-mode hops (Plan -> YOLO, YOLO -> Plan) do not touch the baseline,
3314 // so YOLO's elevated authority never bleeds into the restored Agent
3315 // surface (#3279).
3316 if previous_mode.uses_agent_baseline() && !self.yolo {
3317 self.mode_prefs = ModeSessionPrefs {
3318 agent_allow_shell: self.allow_shell,
3319 agent_trust_mode: self.trust_mode,
3320 agent_approval_mode: self.approval_mode,
3321 };
3322 }
3323
3324 let policy = base_policy_for_mode(mode, &self.mode_prefs);
3325 self.allow_shell = policy.allow_shell;
3326 self.trust_mode = policy.trust_mode;
3327 self.approval_mode = policy.approval_mode;
3328 self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
3329
3330 self.finish_mode_change(previous_mode);
3331 true
3332 }
3333
3334 /// Legacy YOLO entry points (`--yolo` launch, Alt+Y, the `/mode` yolo
3335 /// alias, `/zidong`). YOLO is a permission change (Full Access + trust + shell),
3336 /// not a mode change: the installed mode stays Act and the elevated
3337 /// authority lives in transient full-access mirrors, never in the durable
3338 /// Agent baseline (#3386/#3279).
3339 pub fn set_mode_yolo_compat(&mut self) -> bool {
3340 // YOLO is a permission change. A locked approval policy must not be
3341 // sidestepped by --yolo, default_mode=yolo, /zidong, or Alt+Y.
3342 if self.approval_policy_locked() {
3343 return false;
3344 }
3345 let previous_mode = self.mode;
3346 // Same baseline-refresh rule as a mode hop: the elevation must not
3347 // bleed into the restored Agent surface.
3348 if previous_mode.uses_agent_baseline() && !self.yolo {
3349 self.mode_prefs = ModeSessionPrefs {
3350 agent_allow_shell: self.allow_shell,
3351 agent_trust_mode: self.trust_mode,
3352 agent_approval_mode: self.approval_mode,
3353 };
3354 }
3355 // The legacy alias always lands in Act, never in Plan or Operate.
3356 self.mode = AppMode::Agent;
3357 // Transient full-access mirrors; do not persist trust/shell elevation
3358 // into the durable Agent baseline.
3359 if self.shell_access_editable {
3360 self.allow_shell = true;
3361 }
3362 self.trust_mode = true;
3363 self.approval_mode = ApprovalMode::Bypass;
3364 self.yolo = true;
3365 self.notify_yolo_compat_once();
3366 self.finish_mode_change(previous_mode);
3367 true
3368 }
3369
3370 /// Apply the legacy YOLO selection from a user-facing entry point
3371 /// (Alt+Y, the `/mode` yolo alias, `/zidong`). A locked approval policy owns the
3372 /// permission surface and refuses here; otherwise this behaves like
3373 /// [`Self::select_mode`] and persists the mode actually installed (Act).
3374 pub fn select_yolo_compat(&mut self) -> SettingSelection {
3375 if self.reject_setting_change_while_busy(MessageId::SettingSubjectMode) {
3376 return SettingSelection::Refused;
3377 }
3378 if self.approval_policy_locked() {
3379 self.push_status_toast(
3380 "Permissions are controlled by config or managed requirements".to_string(),
3381 StatusToastLevel::Warning,
3382 Some(6_000),
3383 );
3384 self.needs_redraw = true;
3385 return SettingSelection::Refused;
3386 }
3387 let changed = self.set_mode_yolo_compat();
3388 self.startup_defaults
3389 .spawn(crate::tui::startup_defaults::StartupDefaults::mode(
3390 self.mode,
3391 ));
3392 if changed {
3393 SettingSelection::Changed
3394 } else {
3395 SettingSelection::PersistedSame
3396 }
3397 }
3398
3399 /// Shared tail of every mode transition: ModeChange hooks plus redraw.
3400 /// Built from `base_hook_context` so this event carries the same session
3401 /// id, workspace, model, and token total as every other event — it used
3402 /// to omit `DEEPSEEK_SESSION_ID` entirely, which made mode transitions
3403 /// uncorrelatable with the session they belonged to.
3404 fn finish_mode_change(&mut self, previous_mode: AppMode) {
3405 let context = self
3406 .base_hook_context()
3407 .with_mode(self.mode.label())
3408 .with_previous_mode(previous_mode.label());
3409 if let Err(error) = self.submit_hooks(HookEvent::ModeChange, context) {
3410 self.surface_observer_hook_submission_failure(error);
3411 }
3412 self.needs_redraw = true;
3413 }
3414
3415 /// Apply a *user-facing* mode selection: change the live session mode and
3416 /// persist it as the startup default.
3417 ///
3418 /// This is the difference between [`Self::set_mode`] and this method.
3419 /// `set_mode` is the session-only primitive — session restore and preset
3420 /// application use it because they are re-installing a mode the user
3421 /// already chose elsewhere, and re-persisting there would let a restored
3422 /// session silently rewrite the startup default. Every interactive
3423 /// selector (Tab/Shift+Tab cycling, the Alt+A/P shortcuts, the hotbar
3424 /// mode actions) goes through here instead, so "I switched to Operate"
3425 /// survives a restart (reported by Hunter against v0.9.1). The legacy
3426 /// YOLO entry points go through [`Self::select_yolo_compat`] instead.
3427 ///
3428 /// The write is queued, not performed here: it is ordered behind every
3429 /// earlier selection by [`StartupDefaultsWriter`], and a failure surfaces
3430 /// through [`Self::drain_startup_default_failures`] rather than being
3431 /// dropped.
3432 ///
3433 /// What is persisted is `self.mode` — the mode `set_mode` actually
3434 /// installed — not the requested enum.
3435 ///
3436 /// The outcome is typed, not a bool, because three things can happen and
3437 /// only one of them means "nothing was saved":
3438 ///
3439 /// - [`SettingSelection::Changed`] — live mode moved *and* the startup
3440 /// default was queued.
3441 /// - [`SettingSelection::PersistedSame`] — live mode was already the
3442 /// requested one, but the startup default was still queued. This is a
3443 /// real, reportable action: after a session restore the live mode and the
3444 /// startup default routinely disagree.
3445 /// - [`SettingSelection::Refused`] — the #2982 turn lock rejected it and
3446 /// nothing was written anywhere.
3447 ///
3448 /// A bool collapsed the last two, so every caller (slash `/mode`, the
3449 /// Alt+A/P/Y shortcuts, the hotbar mode rows) reported a refusal and a
3450 /// successful same-mode save identically — as "already in that mode", with
3451 /// no receipt for the write that did happen.
3452 ///
3453 /// [`StartupDefaultsWriter`]: crate::tui::startup_defaults::StartupDefaultsWriter
3454 pub fn select_mode(&mut self, mode: AppMode) -> SettingSelection {
3455 if self.reject_setting_change_while_busy(MessageId::SettingSubjectMode) {
3456 return SettingSelection::Refused;
3457 }
3458 let changed = self.set_mode(mode);
3459 // Persist an explicit selection even when it matches the live mode.
3460 // A restored session can be Operate while the startup default remains
3461 // Act; choosing Operate again is a request to make the visible state
3462 // durable, not a no-op.
3463 self.startup_defaults
3464 .spawn(crate::tui::startup_defaults::StartupDefaults::mode(
3465 self.mode,
3466 ));
3467 if changed {
3468 SettingSelection::Changed
3469 } else {
3470 SettingSelection::PersistedSame
3471 }
3472 }
3473
3474 /// The receipt for an accepted selection that did not move live state.
3475 ///
3476 /// Without it a same-live selection is indistinguishable from a refusal on
3477 /// screen, even though it wrote the file the user was trying to change.
3478 #[must_use]
3479 pub fn mode_startup_default_receipt(&self, mode: AppMode) -> String {
3480 self.tr(MessageId::ModeAlreadyActiveSavedAsDefault)
3481 .replace("{mode}", mode.display_name())
3482 }
3483
3484 /// Surface any startup-default write that failed since the last drain.
3485 /// Called once per event-loop iteration.
3486 pub fn drain_startup_default_failures(&mut self) {
3487 for failure in self.startup_defaults.drain_failures() {
3488 let message = self.startup_default_failure_message(&failure);
3489 self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
3490 }
3491 }
3492
3493 /// Translate a typed startup-default failure at the locale boundary.
3494 ///
3495 /// The writer runs on a blocking pool and knows nothing about the user's
3496 /// locale, so it reports `StartupDefaultSubject` values and a path-free
3497 /// detail. Turning those into a sentence is this side's job.
3498 #[must_use]
3499 pub fn startup_default_failure_message(
3500 &self,
3501 failure: &crate::tui::startup_defaults::StartupDefaultFailure,
3502 ) -> String {
3503 use crate::tui::startup_defaults::StartupDefaultSubject;
3504
3505 let subject = if failure.subjects.is_empty() {
3506 self.tr(MessageId::StartupDefaultSubjectAll).into_owned()
3507 } else {
3508 failure
3509 .subjects
3510 .iter()
3511 .map(|subject| {
3512 self.tr(match subject {
3513 StartupDefaultSubject::Mode => MessageId::StartupDefaultSubjectMode,
3514 StartupDefaultSubject::Thinking => MessageId::StartupDefaultSubjectThinking,
3515 })
3516 .into_owned()
3517 })
3518 .collect::<Vec<_>>()
3519 // A separator, not a word: composed in code per the crate's
3520 // localization rules.
3521 .join(" + ")
3522 };
3523 self.tr(MessageId::StartupDefaultNotSaved)
3524 .replace("{setting}", &subject)
3525 .replace("{error}", &failure.detail)
3526 }
3527
3528 fn notify_yolo_compat_once(&mut self) {
3529 if self.yolo_compat_notified {
3530 return;
3531 }
3532 self.yolo_compat_notified = true;
3533 // Per-install suppression: check the persisted flag so the toast
3534 // appears exactly once across sessions, not every launch.
3535 if let Ok(settings) = crate::settings::Settings::load()
3536 && settings.yolo_deprecation_shown
3537 {
3538 return;
3539 }
3540 // Persist the flag best-effort; toast still fires even if the write
3541 // fails (retries on the next attempt).
3542 let _ = crate::settings::Settings::transact(|settings| {
3543 settings.yolo_deprecation_shown = true;
3544 Ok(())
3545 });
3546 self.push_status_toast(
3547 "Legacy full-access mode is deprecated — use Act + Full Access (Shift+Tab)".to_string(),
3548 StatusToastLevel::Warning,
3549 Some(8_000),
3550 );
3551 }
3552
3553 /// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
3554 /// pressing Shift+Tab expecting the old thinking cycle land here first.
3555 fn notify_keybinding_migration_once(&mut self) {
3556 if self.keybinding_migration_notified {
3557 return;
3558 }
3559 self.keybinding_migration_notified = true;
3560 self.push_status_toast(
3561 "Shift+Tab now cycles permissions — reasoning effort moved to Ctrl+T".to_string(),
3562 StatusToastLevel::Info,
3563 Some(8_000),
3564 );
3565 }
3566
3567 /// Whether mode/thinking selection is locked because a turn is in flight.
3568 ///
3569 /// While `is_loading`, the model/permission surface the engine is acting on
3570 /// must not shift underneath it, so user-initiated mode and thinking changes
3571 /// are refused (#2982). Returns true (and posts a concise status message) if
3572 /// the change should be rejected — the caller leaves the selection unchanged
3573 /// so the chip "twitches" back instead of moving.
3574 ///
3575 /// `subject` is a `MessageId`, not a `&str`, so the refusal is translated
3576 /// as one sentence in the user's locale instead of splicing an English noun
3577 /// into a translated template.
3578 pub(crate) fn reject_setting_change_while_busy(&mut self, subject: MessageId) -> bool {
3579 if self.is_loading {
3580 let message = self.setting_locked_message(subject);
3581 self.status_message = Some(message);
3582 self.needs_redraw = true;
3583 true
3584 } else {
3585 false
3586 }
3587 }
3588
3589 /// The localized "locked while a turn is running" sentence for `subject`.
3590 #[must_use]
3591 pub(crate) fn setting_locked_message(&self, subject: MessageId) -> String {
3592 self.tr(MessageId::SettingLockedDuringTurn)
3593 .replace("{setting}", self.tr(subject).as_ref())
3594 }
3595
3596 /// Cycle through productive modes: Plan → Act → Operate → Plan.
3597 pub fn cycle_mode(&mut self) {
3598 let next = self.mode.next();
3599 let outcome = self.select_mode(next);
3600 self.report_mode_selection(next, outcome);
3601 }
3602
3603 /// Cycle through modes in reverse.
3604 #[cfg(test)]
3605 pub fn cycle_mode_reverse(&mut self) {
3606 let next = self.mode.previous();
3607 let outcome = self.select_mode(next);
3608 self.report_mode_selection(next, outcome);
3609 }
3610
3611 /// Show the startup-default receipt for a selection that did not move live
3612 /// mode. `Changed` and `Refused` already have their own messaging (the mode
3613 /// chip, and `reject_setting_change_while_busy` respectively).
3614 pub(crate) fn report_mode_selection(&mut self, mode: AppMode, outcome: SettingSelection) {
3615 if outcome == SettingSelection::PersistedSame {
3616 let receipt = self.mode_startup_default_receipt(mode);
3617 self.status_message = Some(receipt);
3618 self.needs_redraw = true;
3619 }
3620 }
3621
3622 /// Cycle reasoning-effort through the active route's distinct tiers.
3623 ///
3624 /// Typed for the same reason as [`Self::select_mode`]: a bool could not tell
3625 /// the hotbar whether the turn lock refused the action or the provider
3626 /// simply exposes a single tier.
3627 pub fn cycle_effort(&mut self) -> SettingSelection {
3628 if self.reject_setting_change_while_busy(MessageId::SettingSubjectThinking) {
3629 return SettingSelection::Refused;
3630 }
3631 let previous = self.reasoning_effort;
3632 self.apply_reasoning_effort_cycle();
3633 if self.reasoning_effort == previous {
3634 SettingSelection::PersistedSame
3635 } else {
3636 SettingSelection::Changed
3637 }
3638 }
3639
3640 /// Advance reasoning effort to the next tier for the active route and
3641 /// surface the change: set a status message and refresh the compaction
3642 /// budget. Auto routing and concrete models alike walk the same ladder as
3643 /// `/model` and `/effort`. Shared by the Ctrl+T shortcut (`cycle_effort`) and the
3644 /// hotbar `reasoning.cycle` action so the two paths cannot drift.
3645 pub(crate) fn apply_reasoning_effort_cycle(&mut self) {
3646 let requested = self.next_reasoning_effort_for_active_route();
3647 self.commit_reasoning_effort(requested);
3648 }
3649
3650 fn next_reasoning_effort_for_active_route(&self) -> ReasoningEffort {
3651 let (provider, base_url, model) = match self.active_reasoning_route_truth() {
3652 Some((provider, _, endpoint, model)) => (provider, endpoint, model),
3653 None => (
3654 self.api_provider,
3655 self.active_route_base_url.as_str(),
3656 self.model.as_str(),
3657 ),
3658 };
3659 // The exact ladder the `/model` picker shows for this route, Auto
3660 // routing included (#6650). On a concrete route every rung is a
3661 // distinct effective tier; under Auto routing the tier is decided at
3662 // dispatch, so neighbouring preferences may still resolve to one tier.
3663 let efforts = crate::tui::model_picker::picker_efforts_for_route(
3664 provider,
3665 base_url,
3666 model,
3667 self.auto_model,
3668 );
3669 // A persisted value the ladder dropped as an alias (DeepSeek `medium`)
3670 // enters at the rung it already resolves to, so the first press moves
3671 // past it instead of re-selecting the same effective tier.
3672 let current = self.reasoning_effort;
3673 let anchor = if self.auto_model || efforts.contains(&current) {
3674 current
3675 } else {
3676 let tier = crate::tui::model_picker::effective_tier_for_route(
3677 current, provider, base_url, model,
3678 );
3679 if efforts.contains(&tier) {
3680 tier
3681 } else {
3682 current
3683 }
3684 };
3685 anchor.cycle_next_in(&efforts)
3686 }
3687
3688 pub(crate) fn commit_reasoning_effort(&mut self, requested: ReasoningEffort) {
3689 let effective = self.effective_reasoning_effort_for_active_route(requested);
3690 let route_truth = self.active_reasoning_route_truth();
3691 let provider_kind = route_truth.map_or(self.api_provider, |(provider, _, _, _)| provider);
3692 let provider = route_truth.map_or_else(
3693 || self.provider_identity_for_persistence().to_string(),
3694 |(_, provider_identity, _, _)| provider_identity.to_string(),
3695 );
3696 let endpoint_identity = route_truth
3697 .map(|(_, _, endpoint, _)| crate::route_receipt::endpoint_identity(endpoint));
3698 let model = route_truth.map(|(_, _, _, model)| model.to_string());
3699 if let Some(work) = self.runtime_services.work.clone()
3700 && let Err(err) = work.record_reasoning_effort_change(
3701 self.current_session_id.as_deref(),
3702 requested.into(),
3703 effective.into(),
3704 provider_kind,
3705 &provider,
3706 endpoint_identity.as_deref(),
3707 model.as_deref(),
3708 )
3709 {
3710 self.status_message = Some(format!(
3711 "Reasoning effort unchanged: Work receipt failed ({err})"
3712 ));
3713 self.needs_redraw = true;
3714 return;
3715 }
3716 self.reasoning_effort = requested;
3717 self.reasoning_effort_preference = Some(requested);
3718 self.invalidate_route_receipts_for_reasoning_change();
3719 // Same persistence owner as the model/effort pickers, so Ctrl+T and the
3720 // hotbar `reasoning.cycle` action restore on restart exactly like a
3721 // picker selection does. Only the *requested* tier is persisted — the
3722 // effective tier is a per-turn route fact, not a user preference.
3723 self.startup_defaults.spawn(
3724 crate::tui::startup_defaults::StartupDefaults::reasoning_effort(requested.as_setting()),
3725 );
3726 self.update_model_compaction_budget();
3727 self.status_message = Some(format!(
3728 "Reasoning effort: {}",
3729 Self::reasoning_effort_resolution_label(requested, effective, self.api_provider)
3730 ));
3731 self.needs_redraw = true;
3732 }
3733
3734 /// Cycle the durable Agent permission posture: Ask → Auto-Review → Bypass.
3735 pub fn cycle_approval_posture(&mut self) -> bool {
3736 let Some(next) = self.next_approval_posture(false) else {
3737 return false;
3738 };
3739 if self.approval_policy_locked() {
3740 self.push_status_toast(
3741 "Permissions are controlled by config or managed requirements".to_string(),
3742 StatusToastLevel::Warning,
3743 Some(6_000),
3744 );
3745 self.needs_redraw = true;
3746 return false;
3747 }
3748 if let Err(err) = Self::persist_permission_posture(next) {
3749 self.push_status_toast(
3750 format!("Permissions were not changed: could not save TUI posture ({err})"),
3751 StatusToastLevel::Warning,
3752 Some(8_000),
3753 );
3754 self.needs_redraw = true;
3755 return false;
3756 }
3757 self.finish_approval_posture_change(next);
3758 true
3759 }
3760
3761 /// Cycle permissions when the only controlling source is the user's
3762 /// editable root `config.toml` key. Shift+Tab is an explicit request to
3763 /// adopt the TUI posture, so persist the next setting first, then remove
3764 /// the shadowing root key. Roll back the setting if that removal fails.
3765 pub fn cycle_root_approval_posture(&mut self) -> bool {
3766 let Some(next) = self.next_approval_posture(true) else {
3767 return false;
3768 };
3769 if !self.approval_policy_root_editable {
3770 self.push_status_toast(
3771 "Permissions are controlled by a non-editable policy source".to_string(),
3772 StatusToastLevel::Warning,
3773 Some(6_000),
3774 );
3775 self.needs_redraw = true;
3776 return false;
3777 }
3778
3779 if let Err(reason) = self.adopt_root_approval_posture(next) {
3780 self.push_status_toast(
3781 format!("Permissions were not changed: {reason}"),
3782 StatusToastLevel::Warning,
3783 Some(8_000),
3784 );
3785 self.needs_redraw = true;
3786 return false;
3787 }
3788
3789 true
3790 }
3791
3792 /// Save a real TUI permission posture and release the user-owned root
3793 /// `approval_policy` that would otherwise shadow it. This is shared by
3794 /// Shift+Tab and the config choice editor so both surfaces make the same
3795 /// atomic transition from raw policy tokens to the three product postures.
3796 pub(crate) fn adopt_root_approval_posture(&mut self, next: ApprovalMode) -> Result<(), String> {
3797 if !self.approval_policy_root_editable {
3798 return Err("the root approval policy is not editable".to_string());
3799 }
3800
3801 let active_config_path = crate::config::resolve_load_config_path(self.config_path.clone())
3802 .map_err(|error| error.to_string())?;
3803 // The posture commit, the root-key release, and the rollback are one
3804 // critical section. Two `Settings::transact` calls would expose the
3805 // uncommitted middle state — a concurrent writer (a queued startup-default
3806 // drain, say) could load the new posture, and the rollback save would then
3807 // also revert whatever that writer had committed in between.
3808 /// Why the critical section ended, carried out so every toast is
3809 /// pushed after the settings lock is released.
3810 enum RootPostureOutcome {
3811 Committed,
3812 Failed(String),
3813 }
3814
3815 let posture = Self::approval_posture_setting(next).to_string();
3816 let outcome = crate::settings::with_settings_transaction(|transaction| {
3817 let mut settings = match transaction.load() {
3818 Ok(settings) => settings,
3819 Err(err) => {
3820 return Ok(RootPostureOutcome::Failed(format!(
3821 "could not load TUI settings ({err})"
3822 )));
3823 }
3824 };
3825 let previous = settings.permission_posture.clone();
3826 settings.permission_posture = Some(posture);
3827 if let Err(err) = transaction.save(&settings) {
3828 return Ok(RootPostureOutcome::Failed(format!(
3829 "could not save TUI posture ({err})"
3830 )));
3831 }
3832
3833 if let Err(err) = crate::config_persistence::persist_unset_root_key(
3834 active_config_path.as_deref(),
3835 "approval_policy",
3836 ) {
3837 settings.permission_posture = previous;
3838 let rollback_note = transaction
3839 .save(&settings)
3840 .err()
3841 .map(|rollback| format!("; settings rollback also failed: {rollback}"))
3842 .unwrap_or_default();
3843 return Ok(RootPostureOutcome::Failed(format!(
3844 "could not release root config policy ({err}){rollback_note}"
3845 )));
3846 }
3847 Ok(RootPostureOutcome::Committed)
3848 })
3849 .unwrap_or_else(|err| {
3850 RootPostureOutcome::Failed(format!("could not lock TUI settings ({err})"))
3851 });
3852 if let RootPostureOutcome::Failed(reason) = outcome {
3853 return Err(reason);
3854 }
3855
3856 self.clear_saved_approval_policy_lock();
3857 self.finish_approval_posture_change(next);
3858 Ok(())
3859 }
3860
3861 fn next_approval_posture(&mut self, allow_root_policy: bool) -> Option<ApprovalMode> {
3862 if self.reject_setting_change_while_busy(MessageId::SettingSubjectPermissions) {
3863 return None;
3864 }
3865 // Plan used to refuse the change outright, which welded the two axes
3866 // together on the keyboard: Tab cycles the mode, Shift+Tab cycles the
3867 // posture, and in Plan the second key silently did nothing. They are
3868 // independent settings and both must stay settable.
3869 //
3870 // Nothing is weakened by allowing it. Plan's read-only guarantee is
3871 // derived from the mode, not from the posture: `authority` maps
3872 // `(Plan, _, Bypass)` to `SandboxPolicy::ReadOnly` (there is a test
3873 // pinning exactly that), and `tool_catalog` gates every write tool on
3874 // `mode != AppMode::Plan`. Setting the posture here records the
3875 // preference that takes effect on the next Act/Operate turn.
3876 if allow_root_policy && !self.approval_policy_root_editable {
3877 return None;
3878 }
3879 Some(self.mode_prefs.agent_approval_mode.cycle_permission_next())
3880 }
3881
3882 fn approval_posture_setting(mode: ApprovalMode) -> &'static str {
3883 match mode {
3884 ApprovalMode::Suggest => "ask",
3885 ApprovalMode::Auto => "auto-review",
3886 ApprovalMode::Bypass => "full-access",
3887 ApprovalMode::Never => "never",
3888 }
3889 }
3890
3891 /// Persist the Shift+Tab permission posture.
3892 ///
3893 /// Synchronous on purpose: `cycle_approval_posture` only moves the live
3894 /// posture if this succeeded, so the keystroke already required the write.
3895 /// It runs inside [`Settings::transact`] so it cannot interleave with a
3896 /// queued mode/thinking write — the two used to load the same bytes and the
3897 /// later save reverted the other's field.
3898 fn persist_permission_posture(next: ApprovalMode) -> anyhow::Result<()> {
3899 Settings::transact(|settings| {
3900 settings.permission_posture = Some(Self::approval_posture_setting(next).to_string());
3901 Ok(())
3902 })
3903 }
3904
3905 fn finish_approval_posture_change(&mut self, next: ApprovalMode) {
3906 self.set_agent_approval_posture(next);
3907 self.needs_redraw = true;
3908 // In Plan the new posture is real but dormant, and the footer chip
3909 // alone would imply it is live. Say when it starts applying instead of
3910 // refusing the change.
3911 if self.mode == AppMode::Plan {
3912 self.push_status_toast(
3913 format!(
3914 "Permissions set to {}. Plan stays Read Only; this applies in Act and Operate.",
3915 next.permission_chip_label()
3916 ),
3917 StatusToastLevel::Info,
3918 Some(5_000),
3919 );
3920 }
3921 // Footer permission chip is canonical — no status toast for the new
3922 // value, only the one-shot rebinding notice.
3923 self.notify_keybinding_migration_once();
3924 }
3925
3926 /// Replace the complete durable Act baseline and project it onto the live
3927 /// runtime when the current mode uses that baseline. Keeping these three
3928 /// fields together prevents setup presets from updating a live mirror while
3929 /// leaving the next Plan → Act transition stale.
3930 pub fn set_agent_runtime_baseline(
3931 &mut self,
3932 allow_shell: bool,
3933 trust_mode: bool,
3934 approval_mode: ApprovalMode,
3935 ) {
3936 self.mode_prefs = ModeSessionPrefs {
3937 agent_allow_shell: allow_shell,
3938 agent_trust_mode: trust_mode,
3939 agent_approval_mode: approval_mode,
3940 };
3941 if self.mode.uses_agent_baseline() {
3942 let policy = base_policy_for_mode(self.mode, &self.mode_prefs);
3943 self.allow_shell = policy.allow_shell;
3944 self.trust_mode = policy.trust_mode;
3945 self.approval_mode = policy.approval_mode;
3946 self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
3947 }
3948 }
3949
3950 #[must_use]
3951 pub(crate) fn agent_trust_baseline(&self) -> bool {
3952 self.mode_prefs.agent_trust_mode
3953 }
3954
3955 /// Update the durable Act shell choice without disturbing trust or
3956 /// approval. The live mirror changes only while Act owns the runtime.
3957 pub fn set_agent_shell_access(&mut self, allow_shell: bool) {
3958 self.set_agent_runtime_baseline(
3959 allow_shell,
3960 self.mode_prefs.agent_trust_mode,
3961 self.mode_prefs.agent_approval_mode,
3962 );
3963 }
3964
3965 /// Host path for `/auto`: persist Auto-Review as the TUI permission
3966 /// posture without inventing a second runtime. Same write as Shift+Tab
3967 /// landing on Auto-Review; Plan stays read-only and only the Act baseline
3968 /// moves.
3969 pub fn apply_auto_review_posture(&mut self) -> Result<(), String> {
3970 if self.reject_setting_change_while_busy(MessageId::SettingSubjectPermissions) {
3971 return Err(self.setting_locked_message(MessageId::SettingSubjectPermissions));
3972 }
3973 if self.approval_policy_locked() {
3974 return Err("Permissions are controlled by config or managed requirements".to_string());
3975 }
3976 Self::persist_permission_posture(ApprovalMode::Auto)
3977 .map_err(|err| format!("could not save TUI posture ({err})"))?;
3978 self.set_agent_approval_posture(ApprovalMode::Auto);
3979 self.needs_redraw = true;
3980 Ok(())
3981 }
3982
3983 /// Update the durable Act approval choice. Entering Full Access enables
3984 /// trust mode; leaving it removes that implicit elevation while preserving
3985 /// an independently enabled trust baseline in other posture transitions.
3986 /// Plan remains read-only.
3987 pub fn set_agent_approval_posture(&mut self, next: ApprovalMode) {
3988 let trust_mode = if next == ApprovalMode::Bypass {
3989 true
3990 } else if self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass {
3991 false
3992 } else {
3993 self.mode_prefs.agent_trust_mode
3994 };
3995 self.set_agent_runtime_baseline(self.mode_prefs.agent_allow_shell, trust_mode, next);
3996 }
3997
3998 #[must_use]
3999 pub fn approval_policy_locked(&self) -> bool {
4000 self.approval_policy_locked
4001 }
4002
4003 #[cfg(test)]
4004 #[must_use]
4005 pub fn approval_policy_requirements_managed(&self) -> bool {
4006 self.approval_policy_requirements_managed
4007 }
4008
4009 /// Session transitions must never detach live runtime producers. Late
4010 /// engine, compaction, purge, or background-task events could otherwise
4011 /// contaminate the replacement session after clear/load/new.
4012 #[must_use]
4013 pub fn session_transition_blocked(&self) -> bool {
4014 // A dispatch still resolving its route, and a locally cancelled turn
4015 // whose terminal event has not landed, both belong to this session:
4016 // switching now would hand the next session a stale suppression that
4017 // cancels its first turn, or a dispatch bound to the old one (U02-10).
4018 self.is_loading
4019 || self.dispatch_in_flight
4020 || self.suppress_stream_events_until_turn_complete
4021 || self.runtime_turn_status.as_deref() == Some("in_progress")
4022 || self.is_compacting
4023 || self.manual_compaction_queued
4024 || self.is_purging
4025 || self
4026 .task_panel
4027 .iter()
4028 .any(|task| matches!(task.status.as_str(), "queued" | "running"))
4029 }
4030
4031 /// Abandon a dispatch still resolving its route or waiting on engine
4032 /// admission (#6800). Its completion closure then arrives promptly, retires
4033 /// `dispatch_in_flight` and restores the unsent message through the normal
4034 /// dispatch-error path. A no-op when no dispatch is outstanding.
4035 pub fn cancel_in_flight_dispatch(&mut self) {
4036 if let Some(cancel) = self.dispatch_cancel.take() {
4037 cancel.cancel();
4038 }
4039 }
4040
4041 /// Whether the interface is asking the user to make a decision. Ambient
4042 /// motion yields across the whole frame while this is true; freezing one
4043 /// task marker still leaves distracting movement in peripheral vision.
4044 #[must_use]
4045 pub fn attention_hold_active(&self) -> bool {
4046 !self.view_stack.is_empty()
4047 || self.pending_user_input_prompt.is_some()
4048 || self
4049 .task_panel
4050 .iter()
4051 .any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
4052 }
4053
4054 pub fn mark_approval_policy_locked(&mut self) {
4055 self.approval_policy_locked = true;
4056 self.approval_policy_root_editable = true;
4057 }
4058
4059 pub fn clear_saved_approval_policy_lock(&mut self) {
4060 if !self.approval_policy_requirements_managed {
4061 self.approval_policy_locked = false;
4062 self.approval_policy_root_editable = false;
4063 }
4064 }
4065
4066 /// Submit observer hooks off the terminal event loop. Foreground in hook
4067 /// configuration still means ordered/awaited within the worker; it no
4068 /// longer means the UI waits on the child process.
4069 pub fn submit_hooks(&self, event: HookEvent, context: HookContext) -> Result<(), String> {
4070 self.hooks.submit_observer(event, context)
4071 }
4072
4073 /// Preserve a lost observer event independently of the ordinary status
4074 /// line. Agent lifecycle handlers immediately replace `status_message`
4075 /// with their normal progress text, so a submission failure belongs in
4076 /// the toast queue instead of that transient slot.
4077 pub fn surface_observer_hook_submission_failure(&mut self, error: String) {
4078 tracing::warn!(target: "hooks", %error, "observer hook was not submitted");
4079 self.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
4080 self.needs_redraw = true;
4081 }
4082
4083 /// Create a hook context with common fields pre-populated
4084 pub fn base_hook_context(&self) -> HookContext {
4085 HookContext::new()
4086 .with_caller(crate::hooks::HookCaller {
4087 workspace: self.workspace.clone(),
4088 plugins: Some(self.extension_plugin_view()),
4089 session_id: self.current_session_id.clone(),
4090 agent_id: self
4091 .agent_focus
4092 .as_ref()
4093 .map(|focus| focus.agent_id.clone()),
4094 origin_turn_id: self.runtime_turn_id.clone(),
4095 origin_call_id: None,
4096 })
4097 .with_mode(self.mode.label())
4098 .with_workspace(self.workspace.clone())
4099 .with_model(&self.model)
4100 .with_session_id(self.hooks.session_id())
4101 .with_tokens(self.session.total_tokens)
4102 }
4103
4104 /// Soft cap on [`Self::history`] length. When history exceeds this count,
4105 /// the oldest cells are folded into a single placeholder to bound memory
4106 /// and render cost (#399 S2). The cap is generous — 5000 cells is more
4107 /// than enough to keep the visible transcript intact across sessions.
4108 pub const HISTORY_SOFT_CAP: usize = 5_000;
4109
4110 /// Number of oldest cells to fold when the soft cap fires. Folding in
4111 /// batches amortizes the cost instead of triggering on every push.
4112 const HISTORY_FOLD_BATCH: usize = 1_000;
4113
4114 pub fn add_message(&mut self, msg: HistoryCell) {
4115 // An in-flight tool is bound to a *virtual* index, `history.len() +
4116 // entry_index`, resolved against `history.len()` at completion time.
4117 // Growing history here without re-basing those bindings makes each of
4118 // them silently mean a different cell, so the completion lands on the
4119 // wrong one and the real row spins forever (#5478 — reproduced by
4120 // `/rename` mid-turn, but every command that reports a message hits it).
4121 self.rebase_active_cell_bindings(1);
4122 let rev = self.fresh_history_revision();
4123 self.history.push(msg);
4124 self.history_revisions.push(rev);
4125 self.history_version = self.history_version.wrapping_add(1);
4126
4127 // Bound history length: when the soft cap fires, fold the oldest
4128 // batch into a single ArchivedContext placeholder.
4129 self.maybe_fold_history();
4130 let selection_has_range = self
4131 .viewport
4132 .transcript_selection
4133 .ordered_endpoints()
4134 .is_some_and(|(start, end)| start != end);
4135 if self.viewport.transcript_scroll.is_at_tail()
4136 && !self.viewport.transcript_selection.dragging
4137 && !selection_has_range
4138 && !self.user_scrolled_during_stream
4139 // While a worker's transcript owns the conversation area, its
4140 // pin governs the visible viewport: main-conversation activity
4141 // must not yank the user's read position in the focused
4142 // transcript (same stick-to-bottom rule as the main pane).
4143 && self
4144 .agent_focus
4145 .as_ref()
4146 .is_none_or(|focus| focus.scroll_top.is_none())
4147 {
4148 self.scroll_to_bottom();
4149 }
4150 }
4151
4152 /// Add `delta` to the parent-turn session cost and bump the displayed
4153 /// high-water mark so the footer total never reverses (#244).
4154 #[cfg(test)]
4155 pub fn accrue_session_cost(&mut self, delta: f64) {
4156 self.accrue_session_cost_estimate(CostEstimate::usd_only(delta));
4157 }
4158
4159 /// Record what a turn's pricing attempt actually produced.
4160 ///
4161 /// Called with the same audit that feeds [`Self::accrue_session_cost_estimate`],
4162 /// so the completeness counters can never drift from the running total.
4163 /// Routes that do not meter money at all (OAuth, token plans, local models)
4164 /// are not counted in either bucket — there is no dollar figure to be
4165 /// incomplete about.
4166 pub fn record_turn_cost_audit(&mut self, audit: &crate::pricing::TurnCostAudit) {
4167 // Provenance is recorded for every audited turn, priced or not: knowing
4168 // *which* row a total was built from is part of explaining the total.
4169 if let Some(provenance) = audit.provenance.as_ref() {
4170 self.session
4171 .cost_pricing_provenances
4172 .insert(provenance.label().to_string());
4173 }
4174 if let Some(defect) = audit.live_pricing_defect.as_ref() {
4175 if audit.estimate.is_some() {
4176 self.session
4177 .cost_live_pricing_defects
4178 .insert(defect.label().to_string());
4179 } else {
4180 self.session
4181 .cost_live_pricing_unusable_defects
4182 .insert(defect.label().to_string());
4183 }
4184 }
4185 // An exactly non-metered route has no dollar figure to be incomplete
4186 // about, so it joins neither coverage bucket. Everything else does,
4187 // including a route whose billing basis could not be established.
4188 if !audit.counts_toward_money_coverage() {
4189 return;
4190 }
4191 for class in &audit.unpriced_classes {
4192 self.session
4193 .cost_unpriced_classes
4194 .insert(class.label().to_string());
4195 }
4196 if !audit.usd_priced
4197 && let Some(reason) = audit.unpriced_reason
4198 {
4199 self.session
4200 .cost_unpriced_reasons
4201 .insert(reason.label().to_string());
4202 }
4203 if !audit.cny_priced {
4204 self.session.cost_cny_unpriced_reasons.insert(
4205 audit
4206 .unpriced_reason
4207 .map_or("currency_not_published", |reason| reason.label())
4208 .to_string(),
4209 );
4210 }
4211 if audit.usd_priced {
4212 self.session.cost_priced_turns = self.session.cost_priced_turns.saturating_add(1);
4213 } else {
4214 self.session.cost_unpriced_turns = self.session.cost_unpriced_turns.saturating_add(1);
4215 }
4216 if audit.cny_priced {
4217 self.session.cost_cny_priced_turns =
4218 self.session.cost_cny_priced_turns.saturating_add(1);
4219 } else {
4220 self.session.cost_cny_unpriced_turns =
4221 self.session.cost_cny_unpriced_turns.saturating_add(1);
4222 }
4223 }
4224
4225 /// Record the route a turn's cost was resolved against, redacted.
4226 pub fn record_turn_cost_route_receipt(&mut self, receipt: String) {
4227 // Bound the set so a session that rotates routes cannot grow it without
4228 // limit; the first 32 distinct routes are more than enough to explain a
4229 // total, and the cap is reported rather than silently truncating.
4230 const MAX_ROUTE_RECEIPTS: usize = 32;
4231 if self.session.cost_route_receipts.len() < MAX_ROUTE_RECEIPTS {
4232 self.session.cost_route_receipts.insert(receipt);
4233 } else {
4234 self.session
4235 .cost_route_receipts
4236 .insert("…additional routes not recorded (receipt cap reached)".to_string());
4237 }
4238 }
4239
4240 /// Fold a drained background-cost pool's coverage into the session's.
4241 ///
4242 /// The caller has already added `pool.estimate` to the running total; this
4243 /// adds the counters and provenance that qualify it, from the same drained
4244 /// value, so the two can never disagree.
4245 pub fn absorb_background_cost_coverage(
4246 &mut self,
4247 pool: &crate::cost_status::PendingBackgroundCost,
4248 ) {
4249 self.session.cost_priced_turns = self
4250 .session
4251 .cost_priced_turns
4252 .saturating_add(pool.priced_turns);
4253 self.session.cost_unpriced_turns = self
4254 .session
4255 .cost_unpriced_turns
4256 .saturating_add(pool.unpriced_turns);
4257 self.session.cost_cny_priced_turns = self
4258 .session
4259 .cost_cny_priced_turns
4260 .saturating_add(pool.cny_priced_turns);
4261 self.session.cost_cny_unpriced_turns = self
4262 .session
4263 .cost_cny_unpriced_turns
4264 .saturating_add(pool.cny_unpriced_turns);
4265 for reason in &pool.unpriced_reasons {
4266 self.session
4267 .cost_unpriced_reasons
4268 .insert((*reason).to_string());
4269 }
4270 for reason in &pool.cny_unpriced_reasons {
4271 self.session
4272 .cost_cny_unpriced_reasons
4273 .insert((*reason).to_string());
4274 }
4275 for class in &pool.unpriced_classes {
4276 self.session
4277 .cost_unpriced_classes
4278 .insert((*class).to_string());
4279 }
4280 for provenance in &pool.pricing_provenances {
4281 self.session
4282 .cost_pricing_provenances
4283 .insert((*provenance).to_string());
4284 }
4285 for defect in &pool.live_pricing_defects {
4286 self.session
4287 .cost_live_pricing_defects
4288 .insert((*defect).to_string());
4289 }
4290 for defect in &pool.live_pricing_unusable_defects {
4291 self.session
4292 .cost_live_pricing_unusable_defects
4293 .insert((*defect).to_string());
4294 }
4295 for receipt in &pool.route_receipts {
4296 self.record_turn_cost_route_receipt(receipt.clone());
4297 }
4298 }
4299
4300 /// Fold one atomic background batch into the live session projection.
4301 /// Returns whether the batch carried runtime-owned response identities and
4302 /// therefore needs a fresh durable snapshot (it may have landed after the
4303 /// ordinary TurnComplete save).
4304 pub fn absorb_pending_background_cost(
4305 &mut self,
4306 pool: &crate::cost_status::PendingBackgroundCost,
4307 ) -> bool {
4308 let pool = crate::cost_status::project_missing_usage_ledger(
4309 &mut self.session.missing_usage_sources,
4310 &mut self.session.missing_usage_overflowed,
4311 &mut self.session.cost_unpriced_turns,
4312 &mut self.session.cost_cny_unpriced_turns,
4313 pool,
4314 );
4315 let runtime_usage_arrived =
4316 !pool.usage_source_fingerprints.is_empty() || pool.missing_usage_overflowed;
4317 self.session
4318 .subagent_usage_sources
4319 .extend(pool.usage_source_fingerprints.iter().cloned());
4320 if pool.estimate.is_positive() {
4321 self.accrue_subagent_cost_estimate(pool.estimate);
4322 }
4323 let add = |slot: &mut Option<u64>, tokens: Option<u64>| {
4324 if let Some(tokens) = tokens {
4325 *slot = Some(slot.unwrap_or(0).saturating_add(tokens));
4326 }
4327 };
4328 add(
4329 &mut self.session.subagent_cache_hit_tokens,
4330 pool.cache_hit_tokens,
4331 );
4332 add(
4333 &mut self.session.subagent_cache_miss_tokens,
4334 pool.cache_miss_tokens,
4335 );
4336 add(
4337 &mut self.session.subagent_cache_write_tokens,
4338 pool.cache_write_tokens,
4339 );
4340 self.absorb_background_cost_coverage(&pool);
4341 runtime_usage_arrived
4342 }
4343
4344 /// Clear every live cost-coverage counter.
4345 ///
4346 /// Used by `/new` and by the session-load path: loading a session must not
4347 /// leave the previous session's priced/unpriced turns attached to a total
4348 /// that no longer contains them (#4318).
4349 pub fn reset_cost_coverage(&mut self) {
4350 self.session.missing_usage_sources.clear();
4351 self.session.missing_usage_overflowed = false;
4352 self.session.cost_priced_turns = 0;
4353 self.session.cost_unpriced_turns = 0;
4354 self.session.cost_cny_priced_turns = 0;
4355 self.session.cost_cny_unpriced_turns = 0;
4356 self.session.cost_unpriced_reasons.clear();
4357 self.session.cost_cny_unpriced_reasons.clear();
4358 self.session.cost_unpriced_classes.clear();
4359 self.session.cost_pricing_provenances.clear();
4360 self.session.cost_live_pricing_defects.clear();
4361 self.session.cost_live_pricing_unusable_defects.clear();
4362 self.session.cost_route_receipts.clear();
4363 self.session.cost_coverage_unknown_legacy = false;
4364 }
4365
4366 /// Add a dual-currency parent-turn cost estimate.
4367 pub fn accrue_session_cost_estimate(&mut self, estimate: CostEstimate) {
4368 let total = CostEstimate {
4369 usd: self.session.session_cost,
4370 cny: self.session.session_cost_cny,
4371 }
4372 .saturating_add(estimate);
4373 self.session.session_cost = total.usd;
4374 self.session.session_cost_cny = total.cny;
4375 self.refresh_displayed_cost_high_water();
4376 }
4377
4378 /// Fold one in-flight model call's priced receipt into the pending-turn
4379 /// estimate so cost surfaces move during a long agentic turn rather than
4380 /// only when it completes. The turn's authoritative price still lands via
4381 /// `accrue_session_cost_estimate` at `TurnComplete`; callers must
4382 /// `clear_pending_turn_cost` there first so nothing counts twice.
4383 pub fn accrue_pending_turn_cost_estimate(&mut self, estimate: CostEstimate) {
4384 let total = CostEstimate {
4385 usd: self.session.pending_turn_cost,
4386 cny: self.session.pending_turn_cost_cny,
4387 }
4388 .saturating_add(estimate);
4389 self.session.pending_turn_cost = total.usd;
4390 self.session.pending_turn_cost_cny = total.cny;
4391 self.refresh_displayed_cost_high_water();
4392 }
4393
4394 /// Drop the in-flight turn's provisional estimate. Called at
4395 /// `TurnComplete` (any outcome) immediately before the authoritative
4396 /// cumulative price accrues; the display stays monotonic through the
4397 /// swap via the high-water mark (#244).
4398 pub fn clear_pending_turn_cost(&mut self) {
4399 self.session.pending_turn_cost = 0.0;
4400 self.session.pending_turn_cost_cny = 0.0;
4401 }
4402
4403 /// Add `delta` to the running sub-agent cost and bump the displayed
4404 /// high-water mark so the footer total never reverses (#244).
4405 #[cfg(test)]
4406 pub fn accrue_subagent_cost(&mut self, delta: f64) {
4407 self.accrue_subagent_cost_estimate(CostEstimate::usd_only(delta));
4408 }
4409
4410 /// Add a dual-currency sub-agent/background cost estimate.
4411 pub fn accrue_subagent_cost_estimate(&mut self, estimate: CostEstimate) {
4412 let total = CostEstimate {
4413 usd: self.session.subagent_cost,
4414 cny: self.session.subagent_cost_cny,
4415 }
4416 .saturating_add(estimate);
4417 self.session.subagent_cost = total.usd;
4418 self.session.subagent_cost_cny = total.cny;
4419 self.refresh_displayed_cost_high_water();
4420 }
4421
4422 /// Copy current session/subagent cost accumulators into session metadata
4423 /// for persistence.
4424 pub fn sync_cost_to_metadata(&self, metadata: &mut crate::session_manager::SessionMetadata) {
4425 metadata.cost.session_cost_usd = self.session.session_cost;
4426 metadata.cost.session_cost_cny = self.session.session_cost_cny;
4427 metadata.cost.subagent_cost_usd = self.session.subagent_cost;
4428 metadata.cost.subagent_cost_cny = self.session.subagent_cost_cny;
4429 metadata.cost.displayed_cost_high_water_usd = self.session.displayed_cost_high_water;
4430 metadata.cost.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny;
4431 // Coverage travels with the money it qualifies. A restored total without
4432 // these fields cannot say what it covers, and its serde defaults read as
4433 // a *complete* total covering zero turns — so they are persisted together
4434 // and `coverage_recorded` marks that this writer actually knew (#4318).
4435 metadata.cost.priced_turns = self.session.cost_priced_turns;
4436 metadata.cost.unpriced_turns = self.session.cost_unpriced_turns;
4437 metadata.cost.cny_priced_turns = self.session.cost_cny_priced_turns;
4438 metadata.cost.cny_unpriced_turns = self.session.cost_cny_unpriced_turns;
4439 metadata.cost.unpriced_reasons = self.session.cost_unpriced_reasons.clone();
4440 metadata.cost.cny_unpriced_reasons = self.session.cost_cny_unpriced_reasons.clone();
4441 metadata.cost.unpriced_classes = self.session.cost_unpriced_classes.clone();
4442 metadata.cost.pricing_provenances = self.session.cost_pricing_provenances.clone();
4443 metadata.cost.live_pricing_defects = self.session.cost_live_pricing_defects.clone();
4444 metadata.cost.live_pricing_unusable_defects =
4445 self.session.cost_live_pricing_unusable_defects.clone();
4446 metadata.cost.route_receipts = self.session.cost_route_receipts.clone();
4447 metadata.cost.usage_source_fingerprints = self
4448 .session
4449 .subagent_usage_sources
4450 .iter()
4451 .cloned()
4452 .collect();
4453 // A session restored as legacy-unknown stays unknown when re-saved:
4454 // re-writing it as "recorded" would launder the missing evidence into an
4455 // apparently complete zero.
4456 metadata.cost.missing_usage_sources = self.session.missing_usage_sources.clone();
4457 metadata.cost.missing_usage_overflowed = self.session.missing_usage_overflowed;
4458 metadata.cost.coverage_recorded = !self.session.cost_coverage_unknown_legacy;
4459 // Persist cumulative turn duration so the footer "worked" chip
4460 // survives session save/restore (#2038).
4461 metadata.cumulative_turn_secs = self.cumulative_turn_duration.as_secs();
4462 }
4463
4464 /// Recompute the displayed cost high-water mark. Called any time a cost
4465 /// counter is mutated; never decreases.
4466 pub fn refresh_displayed_cost_high_water(&mut self) {
4467 let current = CostEstimate {
4468 usd: self.session.session_cost,
4469 cny: self.session.session_cost_cny,
4470 }
4471 .saturating_add(CostEstimate {
4472 usd: self.session.pending_turn_cost,
4473 cny: self.session.pending_turn_cost_cny,
4474 })
4475 .saturating_add(CostEstimate {
4476 usd: self.session.subagent_cost,
4477 cny: self.session.subagent_cost_cny,
4478 });
4479 if current.usd > self.session.displayed_cost_high_water {
4480 self.session.displayed_cost_high_water = current.usd;
4481 }
4482 if current.cny > self.session.displayed_cost_high_water_cny {
4483 self.session.displayed_cost_high_water_cny = current.cny;
4484 }
4485 }
4486
4487 /// Read the visible session+sub-agent cost. Guaranteed monotonic across
4488 /// reconciliation events (cache adjustments, provisional → final swaps)
4489 /// for the lifetime of one session (#244).
4490 #[cfg(test)]
4491 pub fn displayed_session_cost(&self) -> f64 {
4492 self.displayed_session_cost_for_currency(CostCurrency::Usd)
4493 }
4494
4495 /// Read the visible session+sub-agent cost in the chosen currency.
4496 pub fn displayed_session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
4497 match self.cost_display_currency(currency) {
4498 CostCurrency::Usd => {
4499 let current = CostEstimate {
4500 usd: self.session.session_cost,
4501 cny: 0.0,
4502 }
4503 .saturating_add(CostEstimate {
4504 usd: self.session.pending_turn_cost,
4505 cny: 0.0,
4506 })
4507 .saturating_add(CostEstimate {
4508 usd: self.session.subagent_cost,
4509 cny: 0.0,
4510 })
4511 .usd;
4512 current.max(self.session.displayed_cost_high_water)
4513 }
4514 CostCurrency::Cny => {
4515 let current = CostEstimate {
4516 usd: 0.0,
4517 cny: self.session.session_cost_cny,
4518 }
4519 .saturating_add(CostEstimate {
4520 usd: 0.0,
4521 cny: self.session.pending_turn_cost_cny,
4522 })
4523 .saturating_add(CostEstimate {
4524 usd: 0.0,
4525 cny: self.session.subagent_cost_cny,
4526 })
4527 .cny;
4528 current.max(self.session.displayed_cost_high_water_cny)
4529 }
4530 }
4531 }
4532
4533 /// The session's own display share: settled turns plus the in-flight
4534 /// turn's provisional estimate (the running turn's money is session
4535 /// money, so the sidebar breakdown keeps summing to the displayed total
4536 /// mid-turn).
4537 pub fn session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
4538 match self.cost_display_currency(currency) {
4539 CostCurrency::Usd => self.session.session_cost + self.session.pending_turn_cost,
4540 CostCurrency::Cny => self.session.session_cost_cny + self.session.pending_turn_cost_cny,
4541 }
4542 }
4543
4544 pub fn subagent_cost_for_currency(&self, currency: CostCurrency) -> f64 {
4545 match self.cost_display_currency(currency) {
4546 CostCurrency::Usd => self.session.subagent_cost,
4547 CostCurrency::Cny => self.session.subagent_cost_cny,
4548 }
4549 }
4550
4551 pub fn format_cost_amount(&self, amount: f64) -> String {
4552 crate::pricing::format_cost_amount(amount, self.cost_display_currency(self.cost_currency))
4553 }
4554
4555 /// A [`CostEstimate`] in the session's display currency — the same rule
4556 /// `format_cost_amount` applies, so a turn receipt and the session total
4557 /// never disagree on `$` versus `¥`.
4558 pub fn format_cost_estimate(&self, estimate: CostEstimate) -> String {
4559 crate::pricing::format_cost_estimate(
4560 estimate,
4561 self.cost_display_currency(self.cost_currency),
4562 )
4563 }
4564
4565 /// Price is one number, everywhere (design §2.11 item 5): the session
4566 /// cost as the footer chip, the price view, and the roster print it.
4567 /// Money routes print the amount; subscription, local, and unknown
4568 /// routes print the same words the chip uses; a metered route that has
4569 /// not spent yet prints `$0.00` rather than vanishing between turns.
4570 #[must_use]
4571 pub fn session_cost_label(&self) -> String {
4572 let chip = self.cumulative_usage_chip();
4573 crate::route_billing::format_usage_chip(&chip, self.ui_locale).unwrap_or_else(|| {
4574 self.format_cost_amount(self.displayed_session_cost_for_currency(self.cost_currency))
4575 })
4576 }
4577
4578 pub fn format_cost_amount_precise(&self, amount: f64) -> String {
4579 crate::pricing::format_cost_amount_precise(
4580 amount,
4581 self.cost_display_currency(self.cost_currency),
4582 )
4583 }
4584
4585 pub(crate) fn cost_display_currency(&self, currency: CostCurrency) -> CostCurrency {
4586 if currency == CostCurrency::Cny
4587 && self.session.cost_cny_priced_turns == 0
4588 && self.session.cost_priced_turns > 0
4589 {
4590 CostCurrency::Usd
4591 } else {
4592 currency
4593 }
4594 }
4595
4596 /// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
4597 /// `ArchivedContext` placeholder when history exceeds the soft cap.
4598 /// Called from [`Self::add_message`]; the caller is responsible for
4599 /// also removing the folded range from any auxiliary per-cell maps.
4600 fn maybe_fold_history(&mut self) {
4601 if self.history.len() <= Self::HISTORY_SOFT_CAP {
4602 return;
4603 }
4604
4605 let fold_count = Self::HISTORY_FOLD_BATCH.min(self.history.len());
4606 // Don't fold into the very last cell(s) — keep a buffer of
4607 // non-folded cells so the visible transcript tail stays intact.
4608 let keep_tail = Self::HISTORY_SOFT_CAP.saturating_sub(Self::HISTORY_FOLD_BATCH);
4609 if self.history.len().saturating_sub(fold_count) < keep_tail {
4610 return;
4611 }
4612
4613 // Gather the range of cell indices we are folding.
4614 let folded: Vec<HistoryCell> = self.history.drain(..fold_count).collect();
4615 let folded_revs: Vec<u64> = self.history_revisions.drain(..fold_count).collect();
4616 let _ = folded_revs; // revisions are discarded with the cells
4617
4618 // Shift all per-cell index maps down by `fold_count`.
4619 self.shift_history_maps_down(fold_count);
4620
4621 // Build a single placeholder cell summarizing the folded range.
4622 let total_folded = folded.len();
4623 let summary = format!(
4624 "{total_folded} older transcript cells folded to bound memory. \
4625 Use /sessions to load a prior session snapshot if needed."
4626 );
4627 let placeholder = HistoryCell::ArchivedContext {
4628 level: 0,
4629 range: format!("cells 0-{}", total_folded.saturating_sub(1)),
4630 tokens: String::new(),
4631 density: String::new(),
4632 model: String::new(),
4633 timestamp: String::new(),
4634 summary,
4635 };
4636
4637 // Insert the placeholder at the front.
4638 let rev = self.fresh_history_revision();
4639 self.history.insert(0, placeholder);
4640 self.history_revisions.insert(0, rev);
4641 self.transcript_identity_epoch = self.transcript_identity_epoch.wrapping_add(1);
4642 self.history_version = self.history_version.wrapping_add(1);
4643 self.needs_redraw = true;
4644 }
4645
4646 /// Shift all per-cell index maps down by `n` after removing the first
4647 /// `n` history cells. Every map key >= n is mapped to key - n; keys < n
4648 /// are dropped.
4649 fn shift_history_maps_down(&mut self, n: usize) {
4650 // A folded-range placeholder is inserted at index 0 immediately
4651 // after this shift, so surviving completed-output receipts move down
4652 // by `n` and then forward by one.
4653 self.completed_assistant_outputs.retain_mut(|receipt| {
4654 if receipt.history_index >= n {
4655 receipt.history_index = receipt.history_index - n + 1;
4656 true
4657 } else {
4658 false
4659 }
4660 });
4661
4662 // tool_cells: HashMap<String, usize>
4663 self.tool_cells.retain(|_, idx| {
4664 if *idx >= n {
4665 *idx -= n;
4666 true
4667 } else {
4668 false
4669 }
4670 });
4671
4672 // tool_details_by_cell: HashMap<usize, ToolDetailRecord>
4673 self.tool_details_by_cell = std::mem::take(&mut self.tool_details_by_cell)
4674 .into_iter()
4675 .filter_map(|(idx, detail)| {
4676 if idx >= n {
4677 Some((idx - n, detail))
4678 } else {
4679 None
4680 }
4681 })
4682 .collect();
4683
4684 // context_references_by_cell
4685 self.context_references_by_cell = std::mem::take(&mut self.context_references_by_cell)
4686 .into_iter()
4687 .filter_map(|(idx, refs)| {
4688 if idx >= n {
4689 Some((idx - n, refs))
4690 } else {
4691 None
4692 }
4693 })
4694 .collect();
4695 self.rebuild_session_context_references();
4696
4697 // subagent_card_index
4698 self.subagent_card_index.retain(|_, idx| {
4699 if *idx >= n {
4700 *idx -= n;
4701 true
4702 } else {
4703 false
4704 }
4705 });
4706
4707 // last_fanout_card_index
4708 if let Some(ref mut idx) = self.last_fanout_card_index {
4709 if *idx >= n {
4710 *idx -= n;
4711 } else {
4712 self.last_fanout_card_index = None;
4713 }
4714 }
4715
4716 // collapsed_cells
4717 self.collapsed_cells = std::mem::take(&mut self.collapsed_cells)
4718 .into_iter()
4719 .filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
4720 .collect();
4721 self.thinking_folds.clear();
4722 self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs)
4723 .into_iter()
4724 .filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
4725 .collect();
4726 self.collapsed_cell_map.clear();
4727 }
4728
4729 /// The name a sub-agent was dispatched under, when it has one (#5287).
4730 ///
4731 /// `SubAgentResult::name` carries the session name, which the manager
4732 /// seeds with the agent id and only replaces when the dispatch supplied a
4733 /// name. An id is a lookup handle, never the identity an operator
4734 /// dispatched by, so it is reported as absent here.
4735 fn agent_session_name(&self, agent_id: &str) -> Option<String> {
4736 let agent = self
4737 .subagent_cache
4738 .iter()
4739 .find(|agent| agent.agent_id == agent_id)?;
4740 let name = agent.name.trim();
4741 (!name.is_empty() && name != agent.agent_id).then(|| name.to_string())
4742 }
4743
4744 /// `true` for the `Agent N` counter placeholder assigned before a child's
4745 /// dispatch metadata arrives. Placeholders are the only label that may be
4746 /// upgraded once the child's identity is observed.
4747 fn is_agent_counter_placeholder(label: &str) -> bool {
4748 label
4749 .strip_prefix("Agent ")
4750 .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
4751 }
4752
4753 /// The engine's name for an agent (#6565): resolved from the manager
4754 /// snapshot when there is one, else from the name its spawn or completion
4755 /// event carried. `None` only for a progress-only agent whose identity has
4756 /// not arrived yet, so the caller falls back to a counter placeholder.
4757 fn engine_agent_name(&self, agent_id: &str) -> Option<String> {
4758 self.subagent_cache
4759 .iter()
4760 .find(|agent| agent.agent_id == agent_id)
4761 .map(crate::tools::subagent::subagent_result_display_name)
4762 .or_else(|| {
4763 self.agent_progress_meta
4764 .get(agent_id)
4765 .and_then(|meta| meta.display_name.clone())
4766 })
4767 .map(|name| name.trim().to_string())
4768 .filter(|name| !name.is_empty() && name != agent_id)
4769 }
4770
4771 /// The engine's name, made unique among the labels already shown: two
4772 /// parallel "review" tasks read "review" and "review · 2", the only thing
4773 /// the TUI adds to the engine's name.
4774 fn resolved_identity_label(&mut self, agent_id: &str) -> Option<String> {
4775 let name = self.engine_agent_name(agent_id)?;
4776 let shown_by_another = |candidate: &str| {
4777 self.agent_label_map
4778 .iter()
4779 .any(|(id, shown)| id != agent_id && shown == candidate)
4780 };
4781 let mut unique = name.clone();
4782 let mut sequence = 1u64;
4783 while shown_by_another(&unique) {
4784 sequence += 1;
4785 unique = format!("{name} · {sequence}");
4786 }
4787 Some(unique)
4788 }
4789
4790 fn next_agent_placeholder(&mut self) -> String {
4791 self.agent_counter = self.agent_counter.saturating_add(1);
4792 format!("Agent {}", self.agent_counter)
4793 }
4794
4795 /// The name this agent was *given*: its workflow task label or another
4796 /// explicit nickname, else its dispatch (session) name. `None` for an
4797 /// agent that goes by its role, so a surface can fall back to its own
4798 /// placeholder (a generated whale name on the sidebar).
4799 pub(crate) fn agent_given_name(&self, agent_id: &str) -> Option<String> {
4800 let agent = self
4801 .subagent_cache
4802 .iter()
4803 .find(|agent| agent.agent_id == agent_id)?;
4804 crate::tools::subagent::explicit_nickname(agent_id, agent.nickname.as_deref())
4805 .map(str::to_string)
4806 .or_else(|| self.agent_session_name(agent_id))
4807 }
4808
4809 /// #3030: return the stable user-facing label for an agent id. Labels are
4810 /// resolved from the child's own identity and never downgraded once set;
4811 /// only the generic `Agent N` placeholder upgrades when dispatch metadata
4812 /// arrives. A raw agent id is never used as a label.
4813 pub(crate) fn ensure_agent_label(&mut self, agent_id: &str) -> String {
4814 let existing = self.agent_label_map.get(agent_id).cloned();
4815 if let Some(existing) = existing {
4816 if !Self::is_agent_counter_placeholder(&existing) {
4817 return existing;
4818 }
4819 // Upgrade a placeholder only once identity metadata is available.
4820 if let Some(label) = self.resolved_identity_label(agent_id) {
4821 self.agent_label_map
4822 .insert(agent_id.to_string(), label.clone());
4823 return label;
4824 }
4825 return existing;
4826 }
4827 let label = self
4828 .resolved_identity_label(agent_id)
4829 .unwrap_or_else(|| self.next_agent_placeholder());
4830 self.agent_label_map
4831 .insert(agent_id.to_string(), label.clone());
4832 label
4833 }
4834
4835 /// #3030: read-only label lookup with raw-id fallback for agents the
4836 /// label map has never seen.
4837 pub(crate) fn agent_display_label(&self, agent_id: &str) -> String {
4838 self.agent_label_map
4839 .get(agent_id)
4840 .cloned()
4841 .unwrap_or_else(|| agent_id.to_string())
4842 }
4843
4844 pub fn mark_history_updated(&mut self) {
4845 self.history_version = self.history_version.wrapping_add(1);
4846 // Resync per-cell revisions to history.len(). This is the
4847 // "I-don't-know-which-cell-changed" path: if cells were appended in
4848 // bulk (e.g. session resume, compaction), every new cell gets a
4849 // fresh revision; if cells were removed, drop trailing revs. We
4850 // intentionally do NOT bump revisions for indices that already had
4851 // one — the cache will reuse those. Callers that mutate a specific
4852 // cell's content must call `bump_history_cell(idx)` instead.
4853 self.resync_history_revisions();
4854 self.needs_redraw = true;
4855 }
4856
4857 /// Invalidate only transcript rows whose visible liveness marker is
4858 /// time-based. Animation redraws must not churn settled history, but they
4859 /// do need fresh cache keys for running history and active-cell entries.
4860 pub(crate) fn mark_live_motion_updated(&mut self) {
4861 self.mark_live_motion_updated_inner(true);
4862 }
4863
4864 /// Invalidate only committed live rows. The translation placeholder path
4865 /// already bumps the whole active-cell cache when it changes, so the UI
4866 /// uses this narrower path to avoid bumping that revision twice.
4867 pub(crate) fn mark_live_history_motion_updated(&mut self) {
4868 self.mark_live_motion_updated_inner(false);
4869 }
4870
4871 fn mark_live_motion_updated_inner(&mut self, invalidate_active_cell: bool) {
4872 self.resync_history_revisions();
4873 let live_history_indices: Vec<usize> = self
4874 .history
4875 .iter()
4876 .enumerate()
4877 .filter_map(|(index, cell)| cell.has_live_motion().then_some(index))
4878 .collect();
4879 for index in live_history_indices {
4880 let previous_revision = self.history_revisions.get(index).copied();
4881 let streaming_content_len = (self.streaming_message_index == Some(index))
4882 .then(|| match self.history.get(index) {
4883 Some(HistoryCell::Assistant {
4884 content,
4885 streaming: true,
4886 }) => Some(content.len()),
4887 _ => None,
4888 })
4889 .flatten();
4890 let revision = self.fresh_history_revision();
4891 if let Some(slot) = self.history_revisions.get_mut(index) {
4892 *slot = revision;
4893 }
4894 if let (Some(previous_revision), Some(content_len)) =
4895 (previous_revision, streaming_content_len)
4896 {
4897 let from_revision = self
4898 .streaming_source_receipt
4899 .filter(|receipt| {
4900 receipt.cell_index == index && receipt.to_revision == previous_revision
4901 })
4902 .map_or(previous_revision, |receipt| receipt.from_revision);
4903 self.streaming_source_receipt =
4904 Some(crate::tui::transcript::StreamingSourceReceipt {
4905 cell_index: index,
4906 from_revision,
4907 to_revision: revision,
4908 content_len,
4909 });
4910 }
4911 }
4912
4913 let active_has_live_motion = self
4914 .active_cell
4915 .as_ref()
4916 .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion));
4917 if invalidate_active_cell && active_has_live_motion {
4918 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
4919 if let Some(active) = self.active_cell.as_mut() {
4920 active.bump_revision();
4921 }
4922 }
4923
4924 self.history_version = self.history_version.wrapping_add(1);
4925 self.needs_redraw = true;
4926 }
4927
4928 /// Issue a fresh, monotonically increasing revision counter for a new
4929 /// history cell. Wrapping is acceptable — collisions are astronomically
4930 /// rare and at worst trigger one extra re-render.
4931 fn fresh_history_revision(&mut self) -> u64 {
4932 let rev = self.next_history_revision;
4933 self.next_history_revision = self.next_history_revision.wrapping_add(1);
4934 rev
4935 }
4936
4937 /// Bring `history_revisions` back into shape (`history_revisions.len() ==
4938 /// history.len()`). Pushes fresh revs for newly appended cells, truncates
4939 /// for cells that were removed. **Does not** invalidate existing entries.
4940 pub fn resync_history_revisions(&mut self) {
4941 if self.history_revisions.len() < self.history.len() {
4942 let needed = self.history.len() - self.history_revisions.len();
4943 for _ in 0..needed {
4944 let rev = self.fresh_history_revision();
4945 self.history_revisions.push(rev);
4946 }
4947 } else if self.history_revisions.len() > self.history.len() {
4948 self.history_revisions.truncate(self.history.len());
4949 }
4950 }
4951
4952 /// Bump the revision counter of a single history cell so the transcript
4953 /// cache re-renders it on the next frame. Use this whenever a cell's
4954 /// content (e.g. a streaming Assistant body) is mutated in place.
4955 pub fn bump_history_cell(&mut self, idx: usize) {
4956 // Resync first in case callers mutated `history` directly without
4957 // pushing through `add_message`. After resync, the index is valid
4958 // (or out of bounds — in which case there's nothing to bump).
4959 self.resync_history_revisions();
4960 if self
4961 .streaming_source_receipt
4962 .is_some_and(|receipt| receipt.cell_index == idx)
4963 {
4964 self.streaming_source_receipt = None;
4965 }
4966 if let Some(rev) = self.history_revisions.get_mut(idx) {
4967 let new_rev = self.next_history_revision;
4968 self.next_history_revision = self.next_history_revision.wrapping_add(1);
4969 *rev = new_rev;
4970 }
4971 self.history_version = self.history_version.wrapping_add(1);
4972 self.needs_redraw = true;
4973 }
4974
4975 /// Append a single history cell, allocating a fresh per-cell revision.
4976 /// Equivalent to `add_message` but exposed as a generic alias so call
4977 /// sites currently doing `app.history.push(...)` followed by
4978 /// `app.mark_history_updated()` can collapse to one helper.
4979 pub fn push_history_cell(&mut self, cell: HistoryCell) {
4980 let rev = self.fresh_history_revision();
4981 self.history.push(cell);
4982 self.history_revisions.push(rev);
4983 self.history_version = self.history_version.wrapping_add(1);
4984 self.maybe_fold_history();
4985 self.needs_redraw = true;
4986 }
4987
4988 /// Append a batch of history cells, allocating fresh revisions.
4989 pub fn extend_history<I>(&mut self, cells: I)
4990 where
4991 I: IntoIterator<Item = HistoryCell>,
4992 {
4993 for cell in cells {
4994 let rev = self.fresh_history_revision();
4995 self.history.push(cell);
4996 self.history_revisions.push(rev);
4997 }
4998 self.maybe_fold_history();
4999 self.history_version = self.history_version.wrapping_add(1);
5000 self.needs_redraw = true;
5001 }
5002
5003 /// Clear the history and its session-scoped side indexes. Used by /clear,
5004 /// session reset, and other "wipe and reload" flows.
5005 pub fn clear_history(&mut self) {
5006 self.history.clear();
5007 self.history_revisions.clear();
5008 self.completed_assistant_outputs.clear();
5009 self.context_references_by_cell.clear();
5010 self.session_context_references.clear();
5011 self.session_artifacts.clear();
5012 self.session_turn_outcomes.clear();
5013 self.prune_transcript_index_state(0);
5014 self.history_version = self.history_version.wrapping_add(1);
5015 self.needs_redraw = true;
5016 }
5017
5018 /// Record one user-visible assistant message after its typed completion
5019 /// boundary. Interrupted salvage never calls this path.
5020 pub(crate) fn record_completed_assistant_output(&mut self, history_index: usize, text: &str) {
5021 if text.trim().is_empty() {
5022 return;
5023 }
5024 if let Some(receipt) = self
5025 .completed_assistant_outputs
5026 .iter_mut()
5027 .find(|receipt| receipt.history_index == history_index)
5028 {
5029 receipt.text = text.to_string();
5030 return;
5031 }
5032 self.completed_assistant_outputs
5033 .push(CompletedAssistantOutputReceipt {
5034 history_index,
5035 text: text.to_string(),
5036 });
5037 }
5038
5039 /// Rebuild receipts only from the restored typed transcript projection.
5040 /// `history_cells_from_message` has already routed repair receipts to
5041 /// System cells and omitted interrupted assistant salvage.
5042 pub(crate) fn rebuild_completed_assistant_outputs_from_restored_history(&mut self) {
5043 self.completed_assistant_outputs = self
5044 .history
5045 .iter()
5046 .enumerate()
5047 .filter_map(|(history_index, cell)| match cell {
5048 HistoryCell::Assistant {
5049 content,
5050 streaming: false,
5051 } if !content.trim().is_empty() => Some(CompletedAssistantOutputReceipt {
5052 history_index,
5053 text: content.clone(),
5054 }),
5055 _ => None,
5056 })
5057 .collect();
5058 }
5059
5060 pub(crate) fn completed_assistant_output_receipt(&self) -> Option<&str> {
5061 self.completed_assistant_outputs
5062 .iter()
5063 .rev()
5064 .find(|receipt| !receipt.text.trim().is_empty())
5065 .map(|receipt| receipt.text.as_str())
5066 }
5067
5068 /// Pop the trailing history cell, keeping revisions in sync.
5069 pub fn pop_history(&mut self) -> Option<HistoryCell> {
5070 let cell = self.history.pop();
5071 if cell.is_some() {
5072 self.history_revisions.pop();
5073 self.completed_assistant_outputs
5074 .retain(|receipt| receipt.history_index < self.history.len());
5075 self.context_references_by_cell.remove(&self.history.len());
5076 self.rebuild_session_context_references();
5077 self.prune_transcript_index_state(self.history.len());
5078 self.history_version = self.history_version.wrapping_add(1);
5079 self.needs_redraw = true;
5080 }
5081 cell
5082 }
5083
5084 /// Truncate `history` (and the parallel `history_revisions` + auxiliary
5085 /// per-cell maps) so that only cells with index `< new_len` remain.
5086 /// Used by Esc-Esc backtrack (#133) to roll the visible transcript
5087 /// back to a chosen user message. Cells dropped here are gone — the
5088 /// caller is expected to also trim the matching `api_messages` so the
5089 /// next turn matches what the user sees.
5090 pub fn truncate_history_to(&mut self, new_len: usize) {
5091 if new_len >= self.history.len() {
5092 return;
5093 }
5094 self.history.truncate(new_len);
5095 if self.history_revisions.len() > new_len {
5096 self.history_revisions.truncate(new_len);
5097 }
5098 self.completed_assistant_outputs
5099 .retain(|receipt| receipt.history_index < new_len);
5100 // Drop any auxiliary maps keyed on history indices that now point
5101 // past the new tail. We keep the rest intact so unaffected tool
5102 // cells continue to render correctly.
5103 self.tool_cells.retain(|_, idx| *idx < new_len);
5104 self.tool_details_by_cell.retain(|idx, _| *idx < new_len);
5105 self.context_references_by_cell
5106 .retain(|idx, _| *idx < new_len);
5107 self.rebuild_session_context_references();
5108 self.subagent_card_index.retain(|_, idx| *idx < new_len);
5109 if self
5110 .last_fanout_card_index
5111 .is_some_and(|idx| idx >= new_len)
5112 {
5113 self.last_fanout_card_index = None;
5114 }
5115 self.prune_transcript_index_state(new_len);
5116 self.history_version = self.history_version.wrapping_add(1);
5117 self.needs_redraw = true;
5118 }
5119
5120 pub(crate) fn prune_transcript_index_state(&mut self, len: usize) {
5121 self.transcript_identity_epoch = self.transcript_identity_epoch.wrapping_add(1);
5122 self.collapsed_cells.retain(|idx| *idx < len);
5123 self.thinking_folds.retain(|idx, _| *idx < len);
5124 self.expanded_tool_runs.retain(|idx| *idx < len);
5125 self.collapsed_cell_map.clear();
5126 }
5127
5128 /// Mutable access to the shared transcript mirror. Copy-on-write: an
5129 /// exclusive `Arc` mutates in place, a shared one detaches first, so an
5130 /// outstanding engine snapshot can never observe the mutation.
5131 pub fn api_messages_mut(&mut self) -> &mut Vec<Message> {
5132 Arc::make_mut(&mut self.api_messages)
5133 }
5134
5135 /// Append a message and stamp when it landed — the persisted journal's
5136 /// `created_at` reads this stamp, so an entry's time is append time, not
5137 /// save time.
5138 pub fn push_api_message(&mut self, message: Message) {
5139 self.api_message_stamps
5140 .resize_with(self.api_messages.len(), Utc::now);
5141 self.api_messages_mut().push(message);
5142 self.api_message_stamps.push(Utc::now());
5143 }
5144
5145 /// Mirror an engine `SessionUpdated` projection into `api_messages`. The
5146 /// unchanged prefix keeps the stamps it already earned — the engine
5147 /// mirrors the same messages back in the same order — and only entries
5148 /// that are new or were rewritten (compaction) are stamped now, which
5149 /// lands within a turn-event of the real append. The shared snapshot is
5150 /// installed without copying.
5151 pub fn set_api_messages(&mut self, messages: Arc<Vec<Message>>) {
5152 let keep = self
5153 .api_messages
5154 .iter()
5155 .zip(messages.iter())
5156 .take_while(|(old, new)| old == new)
5157 .count()
5158 .min(self.api_message_stamps.len());
5159 self.api_message_stamps.truncate(keep);
5160 self.api_message_stamps
5161 .resize_with(messages.len(), Utc::now);
5162 self.api_messages = messages;
5163 }
5164
5165 /// Install a resumed conversation, reusing the persisted journal's
5166 /// per-entry `created_at` as the stamps so a next save does not rewrite
5167 /// history to resume time. Entries without a matching stamp fall back to
5168 /// now.
5169 pub fn restore_api_messages(
5170 &mut self,
5171 messages: Vec<Message>,
5172 session: &crate::session_manager::SavedSession,
5173 ) {
5174 let journal = session.journal.clone().unwrap_or_else(|| {
5175 crate::session_tree::SessionJournal::from_messages(
5176 session.messages.clone(),
5177 session.metadata.spawn_depth,
5178 )
5179 });
5180 self.install_restored_api_messages(messages, journal, session.journal_message_stamps());
5181 }
5182
5183 /// [`Self::restore_api_messages`] for a caller that owns the loaded
5184 /// session: the journal and the message history are *moved* out of it,
5185 /// not cloned, and the history goes through the owned restore projection,
5186 /// so a resume holds one copy of the transcript instead of three (memory
5187 /// note M3). `session.journal` and `session.messages` are left empty.
5188 pub fn restore_api_messages_from_owned(
5189 &mut self,
5190 session: &mut crate::session_manager::SavedSession,
5191 ) {
5192 let stamps = session.journal_message_stamps();
5193 let journal = match session.journal.take() {
5194 Some(journal) => journal,
5195 // Legacy session without a journal: rebuild it from the saved
5196 // history, as the borrowing path does.
5197 None => crate::session_tree::SessionJournal::from_messages(
5198 session.messages.clone(),
5199 session.metadata.spawn_depth,
5200 ),
5201 };
5202 let messages = crate::runtime_handoff::project_owned_messages_for_restore(std::mem::take(
5203 &mut session.messages,
5204 ));
5205 self.install_restored_api_messages(messages, journal, stamps);
5206 }
5207
5208 fn install_restored_api_messages(
5209 &mut self,
5210 messages: Vec<Message>,
5211 journal: crate::session_tree::SessionJournal,
5212 stamps: Vec<DateTime<Utc>>,
5213 ) {
5214 self.session_journal = journal;
5215 self.api_message_stamps = stamps;
5216 self.api_message_stamps
5217 .resize_with(messages.len(), Utc::now);
5218 self.api_messages = Arc::new(messages);
5219 }
5220
5221 /// Append a message with the stamp it earned earlier — used when an
5222 /// undo prune re-inserts preserved tool results that were already in the
5223 /// log.
5224 pub fn push_api_message_stamped(&mut self, message: Message, stamp: DateTime<Utc>) {
5225 self.api_message_stamps
5226 .resize_with(self.api_messages.len(), Utc::now);
5227 self.api_messages_mut().push(message);
5228 self.api_message_stamps.push(stamp);
5229 }
5230
5231 /// `created_at` of each `api_messages` entry, paired positionally.
5232 /// Preserve messages even if older state lacks a stamp; missing times
5233 /// fall back to observation time, as they do when restoring a session.
5234 pub fn api_messages_stamped(&self) -> impl Iterator<Item = (&Message, DateTime<Utc>)> {
5235 self.api_messages.iter().zip(
5236 self.api_message_stamps
5237 .iter()
5238 .copied()
5239 .chain(std::iter::repeat_with(Utc::now)),
5240 )
5241 }
5242
5243 pub fn truncate_api_messages(&mut self, new_len: usize) {
5244 self.api_messages_mut().truncate(new_len);
5245 self.api_message_stamps
5246 .resize_with(self.api_messages.len(), Utc::now);
5247 }
5248
5249 pub fn clear_api_messages(&mut self) {
5250 self.session_journal = crate::session_tree::SessionJournal::new();
5251 self.api_messages_mut().clear();
5252 self.api_message_stamps.clear();
5253 }
5254
5255 #[must_use]
5256 pub fn tool_collapse_active(&self) -> bool {
5257 self.tool_collapse_threshold > 0 && self.tool_collapse_mode.is_active(self.calm_mode)
5258 }
5259
5260 #[must_use]
5261 pub fn tool_run_start_for_history_index(&self, index: usize) -> Option<usize> {
5262 if !self.tool_collapse_active() {
5263 return None;
5264 }
5265 let active_entries = self
5266 .active_cell
5267 .as_ref()
5268 .map_or(&[][..], crate::tui::active_cell::ActiveCell::entries);
5269 if index >= self.history.len().saturating_add(active_entries.len()) {
5270 return None;
5271 }
5272 crate::tui::history::detect_tool_runs_from_slices(
5273 &self.history,
5274 active_entries,
5275 self.tool_collapse_threshold,
5276 )
5277 .into_iter()
5278 .find(|run| index >= run.start && index < run.start.saturating_add(run.count))
5279 .map(|run| run.start)
5280 }
5281
5282 pub fn toggle_tool_run_expansion_at(&mut self, index: usize) -> bool {
5283 let Some(start) = self.tool_run_start_for_history_index(index) else {
5284 return false;
5285 };
5286 if self.expanded_tool_runs.remove(&start) {
5287 self.status_message = Some("Tool group collapsed".to_string());
5288 } else {
5289 self.expanded_tool_runs.insert(start);
5290 self.status_message = Some("Tool group expanded".to_string());
5291 }
5292 self.mark_history_updated();
5293 true
5294 }
5295
5296 /// Bump the active-cell revision counter and request a redraw.
5297 ///
5298 /// Use this whenever an entry inside `active_cell` is mutated. The
5299 /// transcript cache combines this counter with `history_version` to
5300 /// produce a per-cell revision so the synthetic active-cell row can be
5301 /// re-rendered without invalidating committed history cells.
5302 pub fn bump_active_cell_revision(&mut self) {
5303 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
5304 if let Some(active) = self.active_cell.as_mut() {
5305 active.bump_revision();
5306 }
5307 self.history_version = self.history_version.wrapping_add(1);
5308 self.needs_redraw = true;
5309 }
5310
5311 /// Total number of cells in the *virtual* transcript: `history.len()`
5312 /// plus active cell entries (if any).
5313 #[must_use]
5314 pub fn virtual_cell_count(&self) -> usize {
5315 self.history.len() + self.active_cell.as_ref().map_or(0, ActiveCell::entry_count)
5316 }
5317
5318 #[must_use]
5319 pub fn original_cell_index_for_rendered(&self, rendered_index: usize) -> usize {
5320 self.collapsed_cell_map
5321 .get(rendered_index)
5322 .copied()
5323 .unwrap_or(rendered_index)
5324 }
5325
5326 /// Resolve a virtual cell index to either a committed history cell or an
5327 /// active-cell entry. Used by the pager / details lookup code so it can
5328 /// transparently address still-in-flight cells.
5329 #[must_use]
5330 #[allow(dead_code)] // Used by the upcoming pager rewrite (read-only resolver).
5331 pub fn cell_at_virtual_index(&self, index: usize) -> Option<&HistoryCell> {
5332 if index < self.history.len() {
5333 self.history.get(index)
5334 } else {
5335 let entry_idx = index - self.history.len();
5336 self.active_cell
5337 .as_ref()
5338 .and_then(|active| active.entries().get(entry_idx))
5339 }
5340 }
5341
5342 /// Resolve the tool-detail record for a committed or still-active virtual
5343 /// transcript cell.
5344 #[must_use]
5345 pub fn tool_detail_record_for_cell(&self, index: usize) -> Option<&ToolDetailRecord> {
5346 if let Some(detail) = self.tool_details_by_cell.get(&index) {
5347 return Some(detail);
5348 }
5349 self.active_tool_details
5350 .values()
5351 .find(|detail| self.tool_cells.get(&detail.tool_id).copied() == Some(index))
5352 }
5353
5354 /// Whether a virtual transcript cell can open a meaningful `v` detail
5355 /// view. Thinking cells render their own raw text inline so there is no
5356 /// separate "raw" target. Error cells always get a full-message target so
5357 /// recovery instructions cannot be stranded below a short terminal view.
5358 #[must_use]
5359 pub fn cell_has_detail_target(&self, index: usize) -> bool {
5360 self.tool_detail_record_for_cell(index).is_some()
5361 || matches!(
5362 self.cell_at_virtual_index(index),
5363 Some(HistoryCell::Error { .. } | HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
5364 )
5365 }
5366
5367 /// Space owner: selection, newest visible cell, then latest virtual cell.
5368 #[must_use]
5369 pub(crate) fn transcript_action_owner(&self) -> Option<TranscriptActionOwner> {
5370 let meta = self.viewport.transcript_cache.line_meta();
5371 let selected = self
5372 .viewport
5373 .transcript_selection
5374 .ordered_endpoints()
5375 .and_then(|(start, _)| meta.get(start.line_index));
5376 let start = self.viewport.last_transcript_top.min(meta.len());
5377 let end = start
5378 .saturating_add(self.viewport.last_transcript_visible)
5379 .min(meta.len());
5380 let cell_index = selected
5381 .into_iter()
5382 .chain(meta[start..end].iter().rev())
5383 .find_map(|meta| {
5384 meta.cell_line()
5385 .map(|(idx, _)| self.original_cell_index_for_rendered(idx))
5386 .filter(|&idx| self.cell_at_virtual_index(idx).is_some())
5387 })
5388 .or_else(|| self.virtual_cell_count().checked_sub(1))?;
5389 Some(TranscriptActionOwner {
5390 cell_index,
5391 identity_epoch: self.transcript_identity_epoch,
5392 })
5393 }
5394
5395 /// Pick the detail target for the current viewport. The footer hint and
5396 /// Alt+V both resolve through this so they agree on the target.
5397 #[must_use]
5398 pub fn detail_cell_index_for_viewport(
5399 &self,
5400 top: usize,
5401 visible: usize,
5402 line_meta: &[TranscriptLineMeta],
5403 ) -> Option<usize> {
5404 let original = |meta: &TranscriptLineMeta| {
5405 meta.cell_line()
5406 .map(|(idx, _)| self.original_cell_index_for_rendered(idx))
5407 };
5408 let selected = self
5409 .viewport
5410 .transcript_selection
5411 .ordered_endpoints()
5412 .and_then(|(start, _)| line_meta.get(start.line_index))
5413 .and_then(original)
5414 .filter(|&idx| self.cell_has_detail_target(idx));
5415 let start = top.min(line_meta.len().saturating_sub(1));
5416 let end = start.saturating_add(visible).min(line_meta.len());
5417 let mut visible_cells = line_meta[start..end].iter().filter_map(original);
5418 // A visible error is the most urgent detail target. Prefer the newest
5419 // visible error over an earlier tool card so Alt+V opens the failure
5420 // the user is looking at, even when both occupy the viewport.
5421 selected
5422 .or_else(|| {
5423 visible_cells.clone().rev().find(|&idx| {
5424 matches!(
5425 self.cell_at_virtual_index(idx),
5426 Some(HistoryCell::Error { .. })
5427 )
5428 })
5429 })
5430 .or_else(|| visible_cells.find(|&idx| self.cell_has_detail_target(idx)))
5431 .or_else(|| {
5432 (0..self.virtual_cell_count())
5433 .rev()
5434 .find(|&idx| self.cell_has_detail_target(idx))
5435 })
5436 }
5437
5438 pub fn record_context_references(
5439 &mut self,
5440 history_cell: usize,
5441 message_index: usize,
5442 references: Vec<ContextReference>,
5443 ) {
5444 if references.is_empty() {
5445 return;
5446 }
5447 let records: Vec<SessionContextReference> = references
5448 .into_iter()
5449 .map(|reference| SessionContextReference {
5450 message_index,
5451 reference,
5452 })
5453 .collect();
5454 self.context_references_by_cell
5455 .insert(history_cell, records.clone());
5456 self.rebuild_session_context_references();
5457 self.needs_redraw = true;
5458 }
5459
5460 pub fn sync_context_references_from_session(
5461 &mut self,
5462 references: &[SessionContextReference],
5463 message_to_cell: &HashMap<usize, usize>,
5464 ) {
5465 self.context_references_by_cell.clear();
5466 for record in references {
5467 let Some(&cell_index) = message_to_cell.get(&record.message_index) else {
5468 continue;
5469 };
5470 self.context_references_by_cell
5471 .entry(cell_index)
5472 .or_default()
5473 .push(record.clone());
5474 }
5475 self.rebuild_session_context_references();
5476 }
5477
5478 fn rebuild_session_context_references(&mut self) {
5479 let mut records: Vec<SessionContextReference> = self
5480 .context_references_by_cell
5481 .values()
5482 .flat_map(|records| records.iter().cloned())
5483 .collect();
5484 records.sort_by_key(|record| record.message_index);
5485 self.session_context_references = records;
5486 }
5487
5488 /// Mutable variant of [`Self::cell_at_virtual_index`]. Bumps the
5489 /// appropriate revision counter (active-cell revision when targeting an
5490 /// in-flight entry, history version otherwise).
5491 /// Shift every binding that points *into the active cell* up by `added`,
5492 /// to keep it pointing at the same entry after `added` cells are appended
5493 /// to history.
5494 ///
5495 /// Bindings below `history.len()` address finalized cells and must not
5496 /// move. Only called when an active cell exists: with no active cell there
5497 /// are no virtual indices to re-base, and shifting would corrupt real ones.
5498 fn rebase_active_cell_bindings(&mut self, added: usize) {
5499 if added == 0 || self.active_cell.is_none() {
5500 return;
5501 }
5502 let boundary = self.history.len();
5503 for index in self.tool_cells.values_mut() {
5504 if *index >= boundary {
5505 *index = index.saturating_add(added);
5506 }
5507 }
5508 for (cell_index, _) in self.exploring_entries.values_mut() {
5509 if *cell_index >= boundary {
5510 *cell_index = cell_index.saturating_add(added);
5511 }
5512 }
5513 self.active_tool_entry_completed_at =
5514 std::mem::take(&mut self.active_tool_entry_completed_at)
5515 .into_iter()
5516 .map(|(index, at)| {
5517 if index >= boundary {
5518 (index.saturating_add(added), at)
5519 } else {
5520 (index, at)
5521 }
5522 })
5523 .collect();
5524 }
5525
5526 pub fn cell_at_virtual_index_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
5527 if index < self.history.len() {
5528 // Bump only the targeted cell's revision; leave every other
5529 // cell's cached render intact.
5530 self.resync_history_revisions();
5531 if let Some(rev) = self.history_revisions.get_mut(index) {
5532 let new_rev = self.next_history_revision;
5533 self.next_history_revision = self.next_history_revision.wrapping_add(1);
5534 *rev = new_rev;
5535 }
5536 self.history_version = self.history_version.wrapping_add(1);
5537 self.history.get_mut(index)
5538 } else {
5539 let entry_idx = index - self.history.len();
5540 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
5541 self.history_version = self.history_version.wrapping_add(1);
5542 self.active_cell
5543 .as_mut()
5544 .and_then(|active| active.entry_mut(entry_idx))
5545 }
5546 }
5547
5548 /// Drain the active cell into history. Companion maps that reference
5549 /// active-cell entries by virtual index (`tool_cells`,
5550 /// `tool_details_by_cell`) are rewritten to point at the new history
5551 /// indices. Idempotent — calling this when there is no active cell is a
5552 /// no-op.
5553 ///
5554 /// Caller is responsible for first marking in-progress entries with the
5555 /// terminal status they want (e.g. via
5556 /// [`ActiveCell::mark_in_progress_as_interrupted`]).
5557 pub fn flush_active_cell(&mut self) {
5558 let Some(mut active) = self.active_cell.take() else {
5559 self.streaming_thinking_active_entry = None;
5560 return;
5561 };
5562 if active.is_empty() {
5563 self.exploring_cell = None;
5564 self.exploring_entries.clear();
5565 self.active_tool_details.clear();
5566 self.active_tool_entry_completed_at.clear();
5567 self.streaming_thinking_active_entry = None;
5568 self.bump_active_cell_revision();
5569 return;
5570 }
5571
5572 if let Some(entry_idx) = self.streaming_thinking_active_entry.take()
5573 && let Some(HistoryCell::Thinking { streaming, .. }) = active.entry_mut(entry_idx)
5574 {
5575 *streaming = false;
5576 }
5577
5578 let base_index = self.history.len();
5579 // Completed tools are removed from `tool_cells` before the active
5580 // group flushes, but `ActiveCell` deliberately keeps the stable
5581 // tool-to-entry binding until drain. Capture that binding first so
5582 // sequential or parallel tools in one model turn retain distinct raw
5583 // detail records instead of all falling back to the first cell.
5584 let detail_cell_indices: HashMap<String, usize> = self
5585 .active_tool_details
5586 .keys()
5587 .filter_map(|tool_id| {
5588 active
5589 .entry_index_for_tool(tool_id)
5590 .map(|entry_idx| (tool_id.clone(), base_index + entry_idx))
5591 })
5592 .collect();
5593 let drained = active.drain();
5594
5595 let mut details = std::mem::take(&mut self.active_tool_details);
5596 self.active_tool_entry_completed_at.clear();
5597 for (tool_id, detail) in details.drain() {
5598 let cell_index = detail_cell_indices
5599 .get(&tool_id)
5600 .copied()
5601 .or_else(|| self.tool_cells.get(&tool_id).copied())
5602 .unwrap_or(base_index);
5603 self.tool_details_by_cell
5604 .entry(cell_index)
5605 .or_insert(detail);
5606 }
5607
5608 self.exploring_cell = None;
5609 self.exploring_entries.clear();
5610
5611 for cell in drained {
5612 let rev = self.fresh_history_revision();
5613 self.history.push(cell);
5614 self.history_revisions.push(rev);
5615 }
5616 self.history_version = self.history_version.wrapping_add(1);
5617 self.needs_redraw = true;
5618 let selection_has_range = self
5619 .viewport
5620 .transcript_selection
5621 .ordered_endpoints()
5622 .is_some_and(|(start, end)| start != end);
5623 if self.viewport.transcript_scroll.is_at_tail()
5624 && !self.viewport.transcript_selection.dragging
5625 && !selection_has_range
5626 && !self.user_scrolled_during_stream
5627 // While a worker's transcript owns the conversation area, its
5628 // pin governs the visible viewport: main-conversation activity
5629 // must not yank the user's read position in the focused
5630 // transcript (same stick-to-bottom rule as the main pane).
5631 && self
5632 .agent_focus
5633 .as_ref()
5634 .is_none_or(|focus| focus.scroll_top.is_none())
5635 {
5636 self.scroll_to_bottom();
5637 }
5638 // A foreground workflow's finish line waits for its start card to
5639 // leave the active group, so the transcript reads started → finished.
5640 self.announce_settled_workflows();
5641 }
5642
5643 /// Mark every still-running entry in the active cell as interrupted, then
5644 /// flush. Convenience helper for cancellation paths.
5645 pub fn finalize_active_cell_as_interrupted(&mut self) {
5646 if let Some(active) = self.active_cell.as_mut() {
5647 active.mark_in_progress_as_interrupted();
5648 }
5649 // A detached workflow outlives the turn that started it, and a
5650 // foreground one is cancelled by the engine, which says so with its
5651 // own `run_cancelled`. Neither is marked here.
5652 self.flush_active_cell();
5653 }
5654
5655 /// Apply one event to the run it names, creating that run's state on
5656 /// first sight. Runs are independent: a newer run never replaces one that
5657 /// is still going, so ten concurrent workflows are ten rows.
5658 ///
5659 /// Returns whether the event was applied. Only a run's start and end ask
5660 /// for a repaint here; progress leaves it to the caller, which paces a
5661 /// fan-out's event stream (#4095).
5662 pub fn apply_workflow_panel_event(
5663 &mut self,
5664 event_run_id: &str,
5665 event: crate::tui::widgets::workflow_panel::WorkflowPanelEvent,
5666 ) -> bool {
5667 use crate::tui::widgets::workflow_panel::{
5668 WorkflowPanel, WorkflowPanelEvent, WorkflowPanelLifecycle,
5669 };
5670 if event_run_id.trim().is_empty() {
5671 return false;
5672 }
5673 if let WorkflowPanelEvent::RunStarted { run_id, .. } = &event
5674 && run_id != event_run_id
5675 {
5676 return false;
5677 }
5678
5679 let lifecycle = matches!(
5680 &event,
5681 WorkflowPanelEvent::RunStarted { .. }
5682 | WorkflowPanelEvent::RunCompleted { .. }
5683 | WorkflowPanelEvent::RunCancelled { .. }
5684 );
5685 // #5528: a failed run must be loud, not just a row. Capture the
5686 // failure before the event is consumed below; the sticky notice fires
5687 // once per run because the live stream and the tool-complete hydration
5688 // can both deliver the same terminal event.
5689 let run_failure = match &event {
5690 WorkflowPanelEvent::RunCompleted {
5691 status: WorkflowPanelLifecycle::Failed,
5692 error,
5693 ..
5694 } => Some(error.clone()),
5695 _ => None,
5696 };
5697 // A cancelled run stays cancelled (the panel ignores a late
5698 // `run_completed`), so it must not raise a failure notice either.
5699 let existing = self
5700 .workflow_runs
5701 .iter()
5702 .position(|panel| panel.run_id == event_run_id);
5703 let already_failed = existing.is_some_and(|index| {
5704 matches!(
5705 self.workflow_runs[index].lifecycle,
5706 WorkflowPanelLifecycle::Failed | WorkflowPanelLifecycle::Cancelled
5707 )
5708 });
5709 match (existing, &event) {
5710 (Some(index), _) => self.workflow_runs[index].apply_event(event),
5711 (
5712 None,
5713 WorkflowPanelEvent::RunStarted {
5714 run_id,
5715 workflow_goal,
5716 workflow_id,
5717 token_budget,
5718 at_ms,
5719 ..
5720 },
5721 ) => {
5722 let label = workflow_goal
5723 .clone()
5724 .or_else(|| workflow_id.clone())
5725 .unwrap_or_else(|| "workflow".to_string());
5726 let mut panel = WorkflowPanel::new(run_id.clone(), label, *at_ms);
5727 panel.locale = self.ui_locale;
5728 panel.budget_total = *token_budget;
5729 panel.budget_remaining = *token_budget;
5730 self.push_workflow_run(panel);
5731 }
5732 (None, _) => {
5733 // A late event for a run this view never saw start still
5734 // surfaces, under its id, rather than being dropped.
5735 let mut panel = WorkflowPanel::new(event_run_id, event_run_id, 0);
5736 panel.locale = self.ui_locale;
5737 panel.apply_event(event);
5738 self.push_workflow_run(panel);
5739 }
5740 }
5741 if lifecycle {
5742 self.needs_redraw = true;
5743 }
5744 if let Some(error) = run_failure
5745 && !already_failed
5746 {
5747 // The same reason the workbar and the finish row show: a failed
5748 // agent's own cause ("Authorization failed: …") when nothing
5749 // succeeded, not the run's aggregate summary.
5750 let detail = self
5751 .workflow_run(event_run_id)
5752 .and_then(WorkflowPanel::outcome_reason)
5753 .or_else(|| {
5754 error
5755 .as_deref()
5756 .map(str::trim)
5757 .filter(|detail| !detail.is_empty())
5758 .map(str::to_string)
5759 });
5760 let message = match detail.as_deref() {
5761 Some(detail) => format!(
5762 "{} · {}",
5763 self.tr(MessageId::WorkflowRunFailedToast),
5764 bound_agent_activity_text(detail)
5765 ),
5766 None => self.tr(MessageId::WorkflowRunFailedToast).into_owned(),
5767 };
5768 self.set_sticky_status(
5769 message,
5770 StatusToastLevel::Error,
5771 Some(Self::STICKY_ERROR_TTL_MS),
5772 );
5773 }
5774 self.announce_settled_workflows();
5775 true
5776 }
5777
5778 /// Most runs kept at once. Past it the oldest settled run goes first; a
5779 /// live run is never evicted.
5780 const MAX_WORKFLOW_RUNS: usize = 64;
5781
5782 pub(crate) fn push_workflow_run(
5783 &mut self,
5784 panel: crate::tui::widgets::workflow_panel::WorkflowPanel,
5785 ) {
5786 self.workflow_runs.push(panel);
5787 while self.workflow_runs.len() > Self::MAX_WORKFLOW_RUNS {
5788 let Some(oldest_settled) = self
5789 .workflow_runs
5790 .iter()
5791 .position(|run| run.lifecycle.is_terminal() && run.finish_announced)
5792 else {
5793 break;
5794 };
5795 self.workflow_runs.remove(oldest_settled);
5796 }
5797 }
5798
5799 /// The state of one run, by id.
5800 pub(crate) fn workflow_run(
5801 &self,
5802 run_id: &str,
5803 ) -> Option<&crate::tui::widgets::workflow_panel::WorkflowPanel> {
5804 self.workflow_runs.iter().find(|run| run.run_id == run_id)
5805 }
5806
5807 pub(crate) fn workflow_run_mut(
5808 &mut self,
5809 run_id: &str,
5810 ) -> Option<&mut crate::tui::widgets::workflow_panel::WorkflowPanel> {
5811 self.workflow_runs
5812 .iter_mut()
5813 .find(|run| run.run_id == run_id)
5814 }
5815
5816 /// Whether any workflow run is still going.
5817 pub(crate) fn workflow_run_live(&self) -> bool {
5818 self.workflow_runs
5819 .iter()
5820 .any(|run| run.lifecycle.is_running())
5821 }
5822
5823 /// A new turn clears settled rows from the workbar once nothing is still
5824 /// running — their finish lines are already in the transcript. While any
5825 /// run is live the settled ones stay, so a batch reads as one batch.
5826 pub(crate) fn prune_settled_workflow_runs(&mut self) {
5827 if self.workflow_run_live() {
5828 return;
5829 }
5830 let before = self.workflow_runs.len();
5831 self.workflow_runs
5832 .retain(|run| !(run.lifecycle.is_terminal() && run.finish_announced));
5833 if self.workflow_runs.len() != before {
5834 self.needs_redraw = true;
5835 }
5836 }
5837
5838 /// Write each settled run's finish line into the transcript, once. The
5839 /// line is a `workflow` card marked `transcript_line: finished`, so it
5840 /// reuses the card renderer and expands in Transcript mode.
5841 ///
5842 /// One row per run: when the call that started the run is already in
5843 /// history (its call returned, its record still says running) and the
5844 /// conversation has not moved past it, that card becomes the finish — the
5845 /// final state replaces `started` rather than stacking a second row under
5846 /// it. A run that settles after a later user message gets its finish at
5847 /// the tail, where it is seen. The card's tool-detail record is keyed
5848 /// separately and still holds what the model saw.
5849 ///
5850 /// While a `workflow` card is still in the active group (a foreground
5851 /// `run`, or a `start` whose turn has not flushed) the line waits: pushed
5852 /// now it would land above the card that started it. `flush_active_cell`
5853 /// calls back here once the card is in history.
5854 pub(crate) fn announce_settled_workflows(&mut self) {
5855 use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
5856 use crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle;
5857 if !self
5858 .workflow_runs
5859 .iter()
5860 .any(|run| run.lifecycle.is_terminal() && !run.finish_announced)
5861 {
5862 return;
5863 }
5864 let card_in_flight = self.active_cell.as_ref().is_some_and(|active| {
5865 active.entries().iter().any(|cell| {
5866 matches!(cell, HistoryCell::Tool(ToolCell::Generic(tool)) if tool.name == "workflow")
5867 })
5868 });
5869 if card_in_flight {
5870 return;
5871 }
5872 let record_of = |tool: &GenericToolCell| {
5873 tool.output
5874 .as_deref()
5875 .and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
5876 };
5877 // A foreground `run` card that returned its settled record already
5878 // shows the finish (history.rs); writing another would say it twice.
5879 let card_owns_finish = |history: &[HistoryCell], run_id: &str| {
5880 history.iter().rev().any(|cell| {
5881 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
5882 return false;
5883 };
5884 if tool.name != "workflow" || tool.status == ToolStatus::Running {
5885 return false;
5886 }
5887 let Some(value) = record_of(tool) else {
5888 return false;
5889 };
5890 value.get("run_id").and_then(serde_json::Value::as_str) == Some(run_id)
5891 && value.get("transcript_line").is_none()
5892 && matches!(
5893 value.get("status").and_then(serde_json::Value::as_str),
5894 Some("completed" | "succeeded" | "degraded" | "failed" | "cancelled")
5895 )
5896 })
5897 };
5898 // The card that started this run, when it is still showing `started`
5899 // and still in the current exchange. The first card naming a run is
5900 // the one that launched it (the id does not exist before `start`); a
5901 // later `status` poll returns the same running shape and must not be
5902 // mistaken for it. Once the conversation has moved on (a user message
5903 // after the card), rewriting a row far up in scrollback would leave
5904 // nothing at the tail to say the run settled, so the finish is
5905 // appended instead. A card whose call is still running is left alone:
5906 // its result would overwrite the finish.
5907 let start_card = |history: &[HistoryCell], run_id: &str| {
5908 let first = history.iter().position(|cell| {
5909 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
5910 return false;
5911 };
5912 tool.name == "workflow"
5913 && record_of(tool).is_some_and(|value| {
5914 value.get("run_id").and_then(serde_json::Value::as_str) == Some(run_id)
5915 && value.get("transcript_line").is_none()
5916 })
5917 })?;
5918 let HistoryCell::Tool(ToolCell::Generic(tool)) = &history[first] else {
5919 return None;
5920 };
5921 let still_started = tool.status != ToolStatus::Running
5922 && record_of(tool).is_some_and(|value| {
5923 matches!(
5924 value.get("status").and_then(serde_json::Value::as_str),
5925 None | Some("running" | "pending" | "started")
5926 )
5927 });
5928 let same_exchange = !history[first + 1..]
5929 .iter()
5930 .any(|cell| matches!(cell, HistoryCell::User { .. }));
5931 (still_started && same_exchange).then_some(first)
5932 };
5933 let mut replaced = Vec::new();
5934 let mut lines = Vec::new();
5935 for run in &mut self.workflow_runs {
5936 if !run.lifecycle.is_terminal() || run.finish_announced {
5937 continue;
5938 }
5939 run.finish_announced = true;
5940 // A later card (a `status` poll, a foreground `run`) that returned
5941 // the settled record already shows the finish; rewriting the start
5942 // card too would say it twice.
5943 if card_owns_finish(&self.history, &run.run_id) {
5944 continue;
5945 }
5946 let start = start_card(&self.history, &run.run_id);
5947 let mut output = run.to_run_json();
5948 output["transcript_line"] = serde_json::Value::from("finished");
5949 let status = match run.lifecycle {
5950 WorkflowPanelLifecycle::Succeeded => ToolStatus::Success,
5951 WorkflowPanelLifecycle::Degraded => ToolStatus::Warning,
5952 _ => ToolStatus::Failed,
5953 };
5954 if let Some(index) = start
5955 && let Some(HistoryCell::Tool(ToolCell::Generic(card))) =
5956 self.history.get_mut(index)
5957 {
5958 card.status = status;
5959 card.output = Some(output.to_string());
5960 replaced.push(index);
5961 continue;
5962 }
5963 lines.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
5964 name: "workflow".to_string(),
5965 status,
5966 input_summary: None,
5967 output: Some(output.to_string()),
5968 prompts: None,
5969 spillover_path: None,
5970 output_summary: None,
5971 is_diff: false,
5972 })));
5973 }
5974 for index in replaced {
5975 self.bump_history_cell(index);
5976 }
5977 for line in lines {
5978 self.add_message(line);
5979 }
5980 self.needs_redraw = true;
5981 }
5982
5983 /// How long the "press Ctrl+C again to quit" prompt stays armed before it
5984 /// silently expires.
5985 pub const QUIT_CONFIRMATION_WINDOW: Duration = Duration::from_secs(2);
5986
5987 /// Arm the quit confirmation timer. The next Ctrl+C within
5988 /// [`Self::QUIT_CONFIRMATION_WINDOW`] should exit the app cleanly. Call this only
5989 /// from idle state — while a turn is in flight or a modal is open Ctrl+C
5990 /// retains its existing "interrupt this turn" / "close modal" semantics.
5991 ///
5992 /// A live cloud job survives the quit by design (its runner is
5993 /// detached), but its sandbox keeps billing until the next startup
5994 /// sweep reconciles it — so arming the quit prompt also surfaces that
5995 /// cost in the status line. Ctrl+D exits without arming and therefore
5996 /// without this warning.
5997 pub fn arm_quit(&mut self) {
5998 self.quit_armed_until = Some(Instant::now() + Self::QUIT_CONFIRMATION_WINDOW);
5999 // The armed state must be spoken, not silent: surface the localized
6000 // press-again hint as a typed toast with the same lifetime as the
6001 // confirmation window, so the user learns a second Ctrl+C exits.
6002 self.push_status_toast(
6003 self.tr(MessageId::FooterPressCtrlCAgain),
6004 StatusToastLevel::Info,
6005 Some(Self::QUIT_CONFIRMATION_WINDOW.as_millis() as u64),
6006 );
6007 if let Some(warning) = crate::cloud_dispatch::CloudJobStore::from_env()
6008 .ok()
6009 .and_then(|store| crate::cloud_dispatch::live_job_quit_warning(&store))
6010 {
6011 self.push_status_toast(warning, StatusToastLevel::Warning, Some(8_000));
6012 }
6013 }
6014
6015 /// Whether the quit timer is currently armed (i.e. a prior Ctrl+C set it
6016 /// and it hasn't expired yet).
6017 pub fn quit_is_armed(&self) -> bool {
6018 self.quit_armed_until
6019 .map(|deadline| Instant::now() < deadline)
6020 .unwrap_or(false)
6021 }
6022
6023 /// Clear the quit-armed timer. Call when expiry is detected on a tick or
6024 /// when the user takes any other action that should disarm the prompt
6025 /// (typing, sending a message, etc.).
6026 pub fn disarm_quit(&mut self) {
6027 if self.quit_armed_until.is_some() {
6028 self.quit_armed_until = None;
6029 self.needs_redraw = true;
6030 }
6031 }
6032
6033 /// Tick called from the redraw loop. Lets time-based UI state (the
6034 /// quit-armed prompt) expire even when no input event is delivered.
6035 pub fn tick_quit_armed(&mut self) {
6036 if let Some(deadline) = self.quit_armed_until
6037 && Instant::now() >= deadline
6038 {
6039 self.quit_armed_until = None;
6040 self.needs_redraw = true;
6041 }
6042 }
6043
6044 pub const RECEIPT_VISIBLE_DURATION: Duration = Duration::from_secs(8);
6045
6046 pub fn set_receipt_text(&mut self, text: impl Into<String>) {
6047 self.receipt_text = Some(text.into());
6048 self.receipt_started_at = Some(Instant::now());
6049 self.needs_redraw = true;
6050 }
6051
6052 pub fn clear_receipt(&mut self) {
6053 if self.receipt_text.is_some() || self.receipt_started_at.is_some() {
6054 self.receipt_text = None;
6055 self.receipt_started_at = None;
6056 self.needs_redraw = true;
6057 }
6058 }
6059
6060 /// Tick called from the redraw loop so transient receipts leave the UI
6061 /// without waiting for the next keypress.
6062 pub fn tick_receipt(&mut self) {
6063 if self
6064 .receipt_started_at
6065 .is_some_and(|started| started.elapsed() > Self::RECEIPT_VISIBLE_DURATION)
6066 {
6067 self.clear_receipt();
6068 }
6069 }
6070
6071 pub fn close_slash_menu(&mut self) {
6072 self.slash_menu_hidden = true;
6073 self.needs_redraw = true;
6074 }
6075
6076 /// Ceiling on how far the ambient clock advances per sampled frame.
6077 /// Bursty draw schedules (fast token streams) slow the aquarium down
6078 /// instead of teleporting creatures across the gap.
6079 pub const AMBIENT_MAX_STEP_MS: u128 = 160;
6080 /// Gentle-motion grace before a fully idle aquarium settles still.
6081 pub const AMBIENT_IDLE_SETTLE_MS: u64 = 6_000;
6082
6083 /// Advance and read the ambient animation clock. Every decorative
6084 /// position derives from this value; it moves by real elapsed time
6085 /// clamped to [`Self::AMBIENT_MAX_STEP_MS`] per sample, so motion stays
6086 /// continuous no matter how irregular the draw schedule is.
6087 pub fn sample_ambient_clock_ms(&mut self) -> u128 {
6088 let now = Instant::now();
6089 let step = self
6090 .ambient_clock_sampled_at
6091 .map(|last| {
6092 now.duration_since(last)
6093 .as_millis()
6094 .min(Self::AMBIENT_MAX_STEP_MS)
6095 })
6096 .unwrap_or(0);
6097 self.ambient_clock_sampled_at = Some(now);
6098 self.ambient_clock_ms = self.ambient_clock_ms.saturating_add(step);
6099 self.ambient_clock_ms
6100 }
6101
6102 /// Track idleness and report whether the ambient scene has settled.
6103 /// `busy` is the caller's aggregation of live activity signals (running
6104 /// turn, live sub-agents, active durable tasks, completion exhale, user
6105 /// browsing). While busy the idle anchor clears; once quiet, motion gets
6106 /// [`Self::AMBIENT_IDLE_SETTLE_MS`] of grace and then stills.
6107 pub fn ambient_idle_settled(&mut self, busy: bool, now: Instant) -> bool {
6108 if busy {
6109 self.ambient_idle_since = None;
6110 return false;
6111 }
6112 let since = *self.ambient_idle_since.get_or_insert(now);
6113 now.duration_since(since) >= Duration::from_millis(Self::AMBIENT_IDLE_SETTLE_MS)
6114 }
6115
6116 /// Resolve one motion policy for every surface that can request or paint
6117 /// animation. `fancy_animations = false` is a true still mode even when
6118 /// the separate accessibility preference is left at its default.
6119 #[must_use]
6120 pub(crate) fn motion_policy(&self) -> MotionPolicy {
6121 MotionPolicy::from_settings(
6122 self.low_motion,
6123 self.fancy_animations,
6124 self.constrained_frame_rate,
6125 )
6126 }
6127
6128 /// Resolve the `[title] …` window-title prefix for the terminal title.
6129 ///
6130 /// Precedence: the session-level `/title` override wins over the `title`
6131 /// config default; neither configured means no prefix (the historical
6132 /// window titles stay byte-for-byte unchanged). This is deliberately
6133 /// independent of [`session_title`](Self::session_title) — the session
6134 /// *name* keeps identifying the composer border and picker, while this
6135 /// prefix only decorates the terminal window/tab title.
6136 #[must_use]
6137 pub(crate) fn window_title_prefix(&self) -> Option<&str> {
6138 self.window_title
6139 .as_deref()
6140 .or(self.title_default.as_deref())
6141 .filter(|prefix| !prefix.trim().is_empty())
6142 }
6143
6144 /// Bridge the centralized policy into transcript renderers that still
6145 /// accept the legacy boolean motion contract.
6146 #[must_use]
6147 pub(crate) fn effective_low_motion_for_status(&self) -> bool {
6148 self.motion_policy().as_low_motion()
6149 }
6150
6151 pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
6152 TranscriptRenderOptions {
6153 superseded_work_receipt: false,
6154 newest_user_turn: false,
6155 locale: self.ui_locale,
6156 show_thinking: self.show_thinking,
6157 thinking_highlight: self.thinking_highlight,
6158 thinking_default_expanded: self.thinking_default_expanded,
6159 thinking_preview_lines: self.thinking_preview_lines,
6160 verbose: self.verbose_transcript,
6161 show_tool_details: self.show_tool_details,
6162 inline_diff_mode: self.inline_diff_mode,
6163 calm_mode: self.calm_mode,
6164 low_motion: self.effective_low_motion_for_status(),
6165 motion_mode: self.motion_policy().mode(),
6166 spacing: self.transcript_spacing,
6167 palette_mode: self.ui_theme.mode,
6168 prose_measure: self.prose_measure,
6169 }
6170 }
6171
6172 /// Handle terminal resize event.
6173 pub fn handle_resize(&mut self, width: u16, height: u16) {
6174 let preserved_scroll = (!self.viewport.transcript_scroll.is_at_tail())
6175 .then_some(self.viewport.last_transcript_top);
6176 // Wrapped rows already key themselves by width and render options.
6177 // Height changes only affect the final reasoning preview (#6652).
6178 self.viewport.pending_terminal_size = Some(ratatui::layout::Size::new(width, height));
6179
6180 if let Some(top) = preserved_scroll {
6181 self.viewport.transcript_scroll = TranscriptScroll::at_line(top);
6182 }
6183
6184 self.viewport.pending_scroll_delta = 0;
6185 self.viewport.transcript_selection.clear();
6186
6187 self.viewport.last_transcript_area = None;
6188 self.viewport.last_prompt_area = None;
6189 self.viewport.last_transcript_top = 0;
6190 // Seed visible height from the resize event so paging keys use a
6191 // useful page size immediately, before the next render updates it.
6192 self.viewport.last_transcript_visible = (height as usize).saturating_sub(2).max(1);
6193 self.viewport.last_transcript_total = 0;
6194 self.viewport.last_transcript_padding_top = 0;
6195 self.viewport.jump_to_latest_button_area = None;
6196 self.viewport.pinned_prompt_area = None;
6197 self.viewport.pinned_prompt_message = None;
6198
6199 self.needs_redraw = true;
6200 }
6201
6202 pub fn scroll_up(&mut self, amount: usize) {
6203 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
6204 self.viewport.pending_scroll_delta =
6205 self.viewport.pending_scroll_delta.saturating_sub(delta);
6206 self.user_scrolled_during_stream = true;
6207 self.needs_redraw = true;
6208 }
6209
6210 pub fn scroll_down(&mut self, amount: usize) {
6211 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
6212 self.viewport.pending_scroll_delta =
6213 self.viewport.pending_scroll_delta.saturating_add(delta);
6214 self.user_scrolled_during_stream = true;
6215 self.needs_redraw = true;
6216 }
6217
6218 pub fn scroll_to_bottom(&mut self) {
6219 self.viewport.transcript_scroll = TranscriptScroll::to_bottom();
6220 self.viewport.pending_scroll_delta = 0;
6221 self.viewport.jump_to_latest_button_area = None;
6222 self.user_scrolled_during_stream = false;
6223 // While a worker's transcript owns the conversation area, the
6224 // jump-to-bottom affordances (Ctrl+End, Alt+G, the jump-to-latest
6225 // button, sending a follow-up) must release its pin too: the two
6226 // surfaces share one command set, so returning to the live tail has
6227 // to mean the tail the user is actually looking at.
6228 if let Some(focus) = self.agent_focus.as_mut() {
6229 focus.scroll_top = None;
6230 }
6231 self.needs_redraw = true;
6232 }
6233
6234 /// Jump the transcript viewport so rendered line `line` becomes its top
6235 /// row. The pinned prompt header calls this to return to the user message
6236 /// it names. Mirrors the wheel/scrollbar path: pending wheel deltas are
6237 /// dropped so the jump lands where it was asked to, and the viewport
6238 /// leaves the live tail.
6239 pub fn scroll_to_transcript_line(&mut self, line: usize) {
6240 self.viewport.transcript_scroll = TranscriptScroll::at_line(line);
6241 self.viewport.pending_scroll_delta = 0;
6242 // `at_line` is never the tail sentinel, so this reads as `true` today;
6243 // keep the same expression the scrollbar-jump path uses so the two
6244 // stay in step if `at_line` ever clamps to tail on its own.
6245 self.user_scrolled_during_stream = !self.viewport.transcript_scroll.is_at_tail();
6246 self.needs_redraw = true;
6247 }
6248
6249 /// First rendered line of the user message named by the pinned prompt
6250 /// header, resolved against the current transcript layout.
6251 ///
6252 /// The header records the message, not a line offset, and this resolves
6253 /// that identity at click time — a rewrite between paint and click then
6254 /// cannot land the jump on a stale offset. Returns `None` when the
6255 /// message is no longer rendered (collapsed or filtered out), so a stale
6256 /// click cannot teleport the viewport.
6257 pub fn pinned_prompt_target_line(&self) -> Option<usize> {
6258 let message = self.viewport.pinned_prompt_message?;
6259 let map = &self.collapsed_cell_map;
6260 self.viewport
6261 .transcript_cache
6262 .line_meta()
6263 .iter()
6264 .enumerate()
6265 .find_map(|(line_index, meta)| {
6266 let TranscriptLineMeta::CellLine {
6267 cell_index,
6268 line_in_cell: 0,
6269 ..
6270 } = meta
6271 else {
6272 return None;
6273 };
6274 let original = map.get(*cell_index).copied().unwrap_or(*cell_index);
6275 (original == message).then_some(line_index)
6276 })
6277 }
6278
6279 pub fn queue_message(&mut self, message: QueuedMessage) {
6280 self.queued_messages.push_back(message);
6281 }
6282
6283 pub fn pop_queued_message(&mut self) -> Option<QueuedMessage> {
6284 self.queued_messages.pop_front()
6285 }
6286
6287 pub fn remove_queued_message(&mut self, index: usize) -> Option<QueuedMessage> {
6288 self.queued_messages.remove(index)
6289 }
6290
6291 pub fn queued_message_count(&self) -> usize {
6292 self.queued_messages.len()
6293 }
6294
6295 /// Pop the most-recently queued message back into the composer for editing
6296 /// (issue #85 — ↑ affordance). The popped message is parked in
6297 /// [`Self::queued_draft`] so the next Enter re-queues it carrying its
6298 /// original skill instruction. No-op if the composer already has typed
6299 /// content or a draft is already being edited — surfacing the affordance
6300 /// would be ambiguous in either case.
6301 ///
6302 /// Returns `true` when the composer state was mutated.
6303 pub fn pop_last_queued_into_draft(&mut self) -> bool {
6304 if !self.input.is_empty() || self.queued_draft.is_some() {
6305 return false;
6306 }
6307 let Some(msg) = self.queued_messages.pop_back() else {
6308 return false;
6309 };
6310 self.input = msg.display.clone();
6311 self.cursor_position = char_count(&self.input);
6312 self.selected_attachment_index = None;
6313 self.queued_draft = Some(msg);
6314 self.needs_redraw = true;
6315 true
6316 }
6317
6318 /// Stop editing a queued follow-up and put the original queued message back
6319 /// at the tail where [`Self::pop_last_queued_into_draft`] took it from.
6320 pub fn cancel_queued_draft_edit(&mut self) -> bool {
6321 let Some(draft) = self.queued_draft.take() else {
6322 return false;
6323 };
6324 self.queued_messages.push_back(draft);
6325 self.clear_input_recoverable();
6326 self.needs_redraw = true;
6327 true
6328 }
6329
6330 /// Park a legacy pending steer. New keyboard handling routes running-turn
6331 /// drafts through Ctrl+Enter (same-turn steer) or Enter (next-turn
6332 /// follow-up).
6333 #[cfg(test)]
6334 pub fn push_pending_steer(&mut self, message: QueuedMessage) {
6335 self.pending_steers.push_back(message);
6336 self.submit_pending_steers_after_interrupt = true;
6337 self.needs_redraw = true;
6338 }
6339
6340 /// Drain the pending-steer queue and clear the resend flag. Returns the
6341 /// messages in submit order (oldest first).
6342 pub fn drain_pending_steers(&mut self) -> Vec<QueuedMessage> {
6343 self.submit_pending_steers_after_interrupt = false;
6344 if self.pending_steers.is_empty() {
6345 return Vec::new();
6346 }
6347 self.needs_redraw = true;
6348 self.pending_steers.drain(..).collect()
6349 }
6350
6351 /// Decide how to route a fresh non-empty composer submit.
6352 ///
6353 /// Running turns always queue bare-Enter submissions. Ctrl+Enter is the
6354 /// single explicit gesture for amending the active turn, regardless of
6355 /// whether the provider has emitted its first token yet.
6356 ///
6357 /// Truth table:
6358 /// offline=F, busy=F → Immediate
6359 /// offline=F, busy=T, streaming=* → Queue (Ctrl+Enter steers)
6360 /// offline=T, busy=* → Queue
6361 #[must_use]
6362 pub fn decide_submit_disposition(&self) -> SubmitDisposition {
6363 if self.offline_mode {
6364 return SubmitDisposition::Queue;
6365 }
6366 // A spawned dispatch is still resolving route/sending the op (#4605);
6367 // queue rather than spawn a second dispatch that could reorder ops.
6368 if self.dispatch_in_flight {
6369 return SubmitDisposition::Queue;
6370 }
6371 if !self.is_loading {
6372 return SubmitDisposition::Immediate;
6373 }
6374 // Busy: queue the message. Steer is an explicit Ctrl+Enter gesture,
6375 // not a timing-sensitive change in bare Enter behavior.
6376 SubmitDisposition::Queue
6377 }
6378
6379 /// Resolve Enter-shaped input from the same state used by composer hints.
6380 ///
6381 /// Bare Enter is portable across supported terminals: it sends while idle,
6382 /// queues while busy, and an empty Enter promotes the oldest queued message
6383 /// into the active turn. Ctrl+Enter remains accepted when a terminal can
6384 /// report it distinctly, but is intentionally not advertised because many
6385 /// terminals encode it exactly like Enter.
6386 #[must_use]
6387 pub fn decide_composer_submit(&self, chord: ComposerSubmitChord) -> ComposerSubmitAction {
6388 if self.input.is_empty() {
6389 if self.is_loading && self.queued_draft.is_none() && !self.queued_messages.is_empty() {
6390 return ComposerSubmitAction::SendQueuedNow;
6391 }
6392 return ComposerSubmitAction::Noop;
6393 }
6394
6395 let disposition = match chord {
6396 ComposerSubmitChord::Enter => self.decide_submit_disposition(),
6397 ComposerSubmitChord::CtrlEnter
6398 if self.is_loading && !self.offline_mode && !self.dispatch_in_flight =>
6399 {
6400 SubmitDisposition::Steer
6401 }
6402 ComposerSubmitChord::CtrlEnter => self.decide_submit_disposition(),
6403 };
6404 ComposerSubmitAction::Submit(disposition)
6405 }
6406
6407 /// How long after a queued Enter a second, empty Enter still means
6408 /// "send that now" (the grokbuild double-tap, restored 2026-09-02).
6409 pub const DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(500);
6410
6411 /// Resolve what bare Enter should do right now, with double-tap
6412 /// detection.
6413 ///
6414 /// While the engine is busy the first Enter queues and opens the window;
6415 /// a second Enter inside it resolves to `Steer` — the same disposition
6416 /// Ctrl+Enter takes, so there is one steering path. The event loop pairs
6417 /// this with [`Self::take_queued_for_double_tap_steer`] on an empty
6418 /// composer, because the first tap already emptied it. Idle Enter
6419 /// submits immediately and closes any window.
6420 #[must_use]
6421 pub fn enter_with_double_tap(&mut self) -> Option<SubmitDisposition> {
6422 let disposition = self.decide_submit_disposition();
6423 match disposition {
6424 SubmitDisposition::Queue if !self.offline_mode && !self.dispatch_in_flight => {
6425 if self.double_tap_window_open() {
6426 self.last_enter_instant = None;
6427 return Some(SubmitDisposition::Steer);
6428 }
6429 self.last_enter_instant = Some(Instant::now());
6430 Some(SubmitDisposition::Queue)
6431 }
6432 other => {
6433 self.last_enter_instant = None;
6434 Some(other)
6435 }
6436 }
6437 }
6438
6439 /// Open the double-tap window: a queued Enter happened while the engine
6440 /// was busy. The typed-submit path calls this when it queues.
6441 pub fn arm_double_tap_window(&mut self) {
6442 self.last_enter_instant = Some(Instant::now());
6443 }
6444
6445 /// True while a second, empty Enter would send the just-queued message
6446 /// now — the posture bar advertises the gesture exactly this long.
6447 #[must_use]
6448 pub fn double_tap_window_open(&self) -> bool {
6449 self.is_loading
6450 && !self.offline_mode
6451 && !self.dispatch_in_flight
6452 && self
6453 .last_enter_instant
6454 .is_some_and(|instant| instant.elapsed() < Self::DOUBLE_TAP_WINDOW)
6455 }
6456
6457 /// Drain every queued message when the double-tap window is still
6458 /// open, oldest first. Clears the window so a third Enter does not
6459 /// re-steer. The posture bar promises "{enter} again to send now" — with
6460 /// several follow-ups queued, "now" means all of them in order, not just
6461 /// the latest.
6462 pub fn take_queued_for_double_tap_steer(&mut self) -> Vec<QueuedMessage> {
6463 if !self.double_tap_window_open() || self.queued_messages.is_empty() {
6464 return Vec::new();
6465 }
6466 match self.enter_with_double_tap() {
6467 Some(SubmitDisposition::Steer) => self.queued_messages.drain(..).collect(),
6468 _ => Vec::new(),
6469 }
6470 }
6471
6472 /// Mark the in-flight streaming Assistant cell as interrupted: prepend
6473 /// `[interrupted]` to whatever streamed so far (so the user can see what
6474 /// was salvaged) and flip `streaming` off so the spinner halts. No-op if
6475 /// no Assistant cell is currently streaming.
6476 ///
6477 /// Deliberate divergence from openai/codex which discards partial output
6478 /// on abort — V4 thinking is expensive and the user usually wants to see
6479 /// what the model produced before steering.
6480 pub fn finalize_streaming_assistant_as_interrupted(&mut self) {
6481 let Some(index) = self.streaming_message_index.take() else {
6482 return;
6483 };
6484 if let Some(HistoryCell::Assistant { content, streaming }) = self.history.get_mut(index) {
6485 *streaming = false;
6486 if content.is_empty() {
6487 *content = "[interrupted]".to_string();
6488 } else if !content.starts_with("[interrupted]") {
6489 content.insert_str(0, "[interrupted] ");
6490 }
6491 }
6492 self.bump_history_cell(index);
6493 }
6494
6495 /// Retry a `try_lock` up to `retries` times, yielding the thread between
6496 /// attempts. Returns `Some(guard)` on success, `None` if the lock
6497 /// remains contended after all retries. Reached from the async UI/event
6498 /// paths, so this must not park a Tokio worker with `thread::sleep` —
6499 /// `yield_now` covers the microsecond-scale critical sections behind
6500 /// these mutexes, and a still-contended lock degrades to `None`.
6501 fn retry_lock<T>(
6502 mutex: &tokio::sync::Mutex<T>,
6503 retries: u32,
6504 ) -> Option<tokio::sync::MutexGuard<'_, T>> {
6505 for _ in 0..retries {
6506 if let Ok(guard) = mutex.try_lock() {
6507 return Some(guard);
6508 }
6509 std::thread::yield_now();
6510 }
6511 None
6512 }
6513
6514 /// Capture the durable Work state without ever converting lock contention
6515 /// into an empty snapshot.
6516 pub fn work_state_snapshot(&self) -> Result<Option<SessionWorkState>, String> {
6517 if let Some(work) = self.runtime_services.work.as_ref() {
6518 return work
6519 .capture(self.current_session_id.as_deref())
6520 .map(|state| {
6521 state.map(|state| SessionWorkState {
6522 graph: Some(state.graph),
6523 todos: state.todos,
6524 plan: state.plan,
6525 })
6526 });
6527 }
6528 let todos = Self::retry_lock(&self.todos, 100)
6529 .ok_or_else(|| "To-do state is busy; try saving again".to_string())?;
6530 let plan = Self::retry_lock(&self.plan_state, 100)
6531 .ok_or_else(|| "Plan state is busy; try saving again".to_string())?;
6532 let state = SessionWorkState {
6533 graph: None,
6534 todos: todos.snapshot(),
6535 plan: plan.snapshot(),
6536 };
6537 Ok((!state.is_empty()).then_some(state))
6538 }
6539
6540 /// Non-blocking snapshot for the render/event loop. Automatic persistence
6541 /// must skip a contended first save instead of pausing the UI or writing a
6542 /// false empty state.
6543 pub fn try_work_state_snapshot(&mut self) -> Result<Option<SessionWorkState>, String> {
6544 if let Some(work) = self.runtime_services.work.as_ref() {
6545 let state = work
6546 .try_capture(self.current_session_id.as_deref())
6547 .map(|state| {
6548 state.map(|state| SessionWorkState {
6549 graph: Some(state.graph),
6550 todos: state.todos,
6551 plan: state.plan,
6552 })
6553 })?;
6554 self.last_known_work_state = Some(state.clone());
6555 return Ok(state);
6556 }
6557 let todos = self
6558 .todos
6559 .try_lock()
6560 .map_err(|_| "To-do state is busy".to_string())?;
6561 let plan = self
6562 .plan_state
6563 .try_lock()
6564 .map_err(|_| "Plan state is busy".to_string())?;
6565 let state = SessionWorkState {
6566 graph: None,
6567 todos: todos.snapshot(),
6568 plan: plan.snapshot(),
6569 };
6570 let state = (!state.is_empty()).then_some(state);
6571 drop(plan);
6572 drop(todos);
6573 self.last_known_work_state = Some(state.clone());
6574 Ok(state)
6575 }
6576
6577 /// Atomically replace the live Work state from a saved session.
6578 pub fn restore_work_state(
6579 &mut self,
6580 session_id: &str,
6581 workspace: &Path,
6582 state: Option<&SessionWorkState>,
6583 ) -> Result<(), String> {
6584 if let Some(work) = self.runtime_services.work.as_ref() {
6585 let empty = SessionWorkState::default();
6586 let state = state.unwrap_or(&empty);
6587 work.restore_with_workspace_owner_bindings(
6588 session_id,
6589 workspace,
6590 state.graph.as_ref(),
6591 &state.todos,
6592 &state.plan,
6593 )?;
6594 let restored = work.capture(Some(session_id))?;
6595 let normalized_state = restored.map(|state| SessionWorkState {
6596 graph: Some(state.graph),
6597 todos: state.todos,
6598 plan: state.plan,
6599 });
6600 self.work_surface.record_restored_session(
6601 session_id,
6602 normalized_state
6603 .as_ref()
6604 .and_then(|state| state.graph.as_ref()),
6605 );
6606 self.last_known_work_state = Some(normalized_state);
6607 return Ok(());
6608 }
6609 let (restored_todos, restored_plan) = match state {
6610 Some(state) => (
6611 TodoList::from_snapshot(&state.todos)?,
6612 PlanState::from_snapshot(&state.plan),
6613 ),
6614 None => (TodoList::new(), PlanState::default()),
6615 };
6616 let normalized_state = SessionWorkState {
6617 graph: None,
6618 todos: restored_todos.snapshot(),
6619 plan: restored_plan.snapshot(),
6620 };
6621
6622 let mut todos = Self::retry_lock(&self.todos, 100)
6623 .ok_or_else(|| "To-do state is busy; session was not restored".to_string())?;
6624 let mut plan = Self::retry_lock(&self.plan_state, 100)
6625 .ok_or_else(|| "Plan state is busy; session was not restored".to_string())?;
6626 *todos = restored_todos;
6627 *plan = restored_plan;
6628 drop(plan);
6629 drop(todos);
6630 self.work_surface.record_restored_session(session_id, None);
6631 self.last_known_work_state =
6632 Some((!normalized_state.is_empty()).then_some(normalized_state));
6633 Ok(())
6634 }
6635
6636 pub fn clear_todos(&mut self) -> bool {
6637 if let Some(work) = self.runtime_services.work.as_ref() {
6638 if !work.clear(self.current_session_id.as_deref()) {
6639 return false;
6640 }
6641 self.last_known_work_state = Some(None);
6642 return true;
6643 }
6644 // Acquire both stores before mutating either one. `/clear` must never
6645 // report success after clearing only half of the Work surface.
6646 let Some(mut todos) = Self::retry_lock(&self.todos, 100) else {
6647 return false;
6648 };
6649 let Some(mut plan) = Self::retry_lock(&self.plan_state, 100) else {
6650 return false;
6651 };
6652 todos.clear();
6653 *plan = PlanState::default();
6654 drop(plan);
6655 drop(todos);
6656 self.last_known_work_state = Some(None);
6657 true
6658 }
6659
6660 /// Publish a validated Work Graph transaction after a synchronous caller
6661 /// has completed its atomic session write.
6662 pub fn publish_pending_work_state(&mut self) -> Result<bool, String> {
6663 let published = self
6664 .runtime_services
6665 .work
6666 .as_ref()
6667 .map_or(Ok(false), |work| work.publish_pending_sync())?;
6668 Ok(published)
6669 }
6670
6671 pub fn update_model_compaction_budget(&mut self) {
6672 let model = self.effective_model_for_budget().to_string();
6673 self.compact_threshold = crate::route_budget::compaction_threshold_for_route_at_percent(
6674 self.api_provider,
6675 &model,
6676 self.active_route_limits,
6677 self.auto_compact_threshold_percent,
6678 );
6679 if !self.auto_compact_user_configured {
6680 self.auto_compact = crate::route_budget::auto_compact_default_for_route(
6681 self.api_provider,
6682 &model,
6683 self.active_route_limits,
6684 );
6685 }
6686 }
6687
6688 pub fn set_active_route_limits(&mut self, limits: RouteLimits) {
6689 self.active_route_limits = crate::route_budget::known_route_limits(limits);
6690 }
6691
6692 /// Install an already-resolved runtime route receipt in one operation so
6693 /// endpoint-sensitive reasoning and context reporting cannot drift apart.
6694 pub fn set_active_route_resolution(
6695 &mut self,
6696 base_url: impl Into<String>,
6697 limits: RouteLimits,
6698 context_window_source: crate::route_runtime::ContextWindowSource,
6699 ) {
6700 self.active_route_base_url = base_url.into();
6701 self.set_active_route_limits(limits);
6702 self.active_context_window_source = context_window_source;
6703 }
6704
6705 /// Refresh the operator-configured windows for the active provider
6706 /// identity: the provider-level default plus its per-model table (#6108).
6707 pub fn set_active_context_window_override(
6708 &mut self,
6709 config: &crate::config::Config,
6710 identity: &crate::config::ProviderIdentity,
6711 ) {
6712 self.active_context_window_override = config.context_window_for_provider_config(identity);
6713 self.active_model_context_windows = config.model_context_windows_for(identity).cloned();
6714 if let Some(resolution) = self.configured_context_window_for(&self.model.clone()) {
6715 self.active_context_window_source = resolution.source;
6716 }
6717 if self.active_route_limits.is_none() {
6718 self.active_route_limits = self.context_window_override_limits();
6719 }
6720 }
6721
6722 /// Effective operator-configured window for an exact wire model id on the
6723 /// active provider: a `model_context_windows` hit wins over the provider
6724 /// default (#6108). `None` when the operator configured neither rung.
6725 pub(crate) fn configured_context_window_for(
6726 &self,
6727 model: &str,
6728 ) -> Option<crate::route_runtime::ContextWindowResolution> {
6729 self.active_model_context_windows
6730 .as_ref()
6731 .and_then(|table| table.get(model).copied())
6732 .filter(|window| *window > 0)
6733 .map(|tokens| crate::route_runtime::ContextWindowResolution {
6734 tokens,
6735 source: crate::route_runtime::ContextWindowSource::ConfiguredModel,
6736 })
6737 .or_else(|| {
6738 self.active_context_window_override
6739 .filter(|window| *window > 0)
6740 .map(|tokens| crate::route_runtime::ContextWindowResolution {
6741 tokens,
6742 source: crate::route_runtime::ContextWindowSource::Configured,
6743 })
6744 })
6745 }
6746
6747 pub fn context_window_override_limits(&self) -> Option<RouteLimits> {
6748 self.configured_context_window_for(&self.model)
6749 .map(|resolution| RouteLimits {
6750 context_tokens: Some(u64::from(resolution.tokens)),
6751 ..RouteLimits::default()
6752 })
6753 }
6754
6755 pub fn set_model_selection(&mut self, model: String) {
6756 let auto_model = model.trim().eq_ignore_ascii_case("auto");
6757 self.model = if auto_model {
6758 "auto".to_string()
6759 } else {
6760 model
6761 };
6762 self.auto_model = auto_model;
6763 self.last_effective_model = None;
6764 self.last_effective_provider = None;
6765 self.last_effective_provider_identity = None;
6766 self.last_auto_route_receipt = None;
6767 self.pending_auto_route_receipt = None;
6768 self.last_effective_reasoning_effort = None;
6769 // Auto model routing is independent from an explicitly requested raw
6770 // reasoning tier. Never reuse the route-normalized live value here:
6771 // fixed DeepSeek can collapse low→high and Codex off→low.
6772 if auto_model {
6773 self.reasoning_effort = self
6774 .reasoning_effort_preference
6775 .unwrap_or(ReasoningEffort::Auto);
6776 } else {
6777 let requested = self
6778 .reasoning_effort_preference
6779 .unwrap_or(self.reasoning_effort);
6780 self.reasoning_effort = requested.normalize_for_provider(self.api_provider);
6781 }
6782 }
6783
6784 pub fn model_selection_for_persistence(&self) -> String {
6785 if self.auto_model || self.model.trim().eq_ignore_ascii_case("auto") {
6786 "auto".to_string()
6787 } else {
6788 self.model.clone()
6789 }
6790 }
6791
6792 /// Atomic latest Auto route metadata for session snapshots. The provider,
6793 /// exact identity, model, and receipt are either persisted together or
6794 /// omitted together so a resumed session cannot display a mixed route.
6795 #[must_use]
6796 pub(crate) fn auto_route_for_persistence(
6797 &self,
6798 ) -> Option<crate::session_manager::SavedAutoRouteReceipt> {
6799 if !self.auto_model {
6800 return None;
6801 }
6802 let (provider, model, receipt) = (
6803 self.last_effective_provider?,
6804 self.last_effective_model.as_ref()?,
6805 self.last_auto_route_receipt.as_ref()?,
6806 );
6807 if model.trim().is_empty() {
6808 return None;
6809 }
6810 let provider_identity = self
6811 .last_effective_provider_identity
6812 .clone()
6813 .unwrap_or_else(|| {
6814 if provider == ProviderKind::Custom {
6815 self.provider_identity_for_persistence().to_string()
6816 } else {
6817 provider.as_str().to_string()
6818 }
6819 });
6820 Some(crate::session_manager::SavedAutoRouteReceipt {
6821 provider,
6822 provider_identity,
6823 model: model.clone(),
6824 receipt: receipt.clone(),
6825 effective_reasoning_effort: self.last_effective_reasoning_effort.map(Into::into),
6826 })
6827 }
6828
6829 #[must_use]
6830 pub(crate) fn provider_identity_for_persistence(&self) -> &str {
6831 self.provider_identity
6832 .as_ref()
6833 .map_or("unavailable", |identity| identity.key.as_str())
6834 }
6835
6836 #[must_use]
6837 pub(crate) fn provider_id_for_persistence(&self) -> Option<&str> {
6838 self.provider_identity
6839 .as_ref()
6840 .and_then(crate::config::ProviderIdentity::persisted_id)
6841 }
6842
6843 #[cfg(test)]
6844 pub(crate) fn set_provider_identity(
6845 &mut self,
6846 provider: ProviderKind,
6847 identity: impl Into<String>,
6848 ) {
6849 let key: String = identity.into();
6850 // This fixture helper has no parsed Config proof for an absent ID.
6851 // Legacy-root tests must install their actually captured record instead.
6852 let exact_id = Some(key.clone().into());
6853 self.set_provider_identity_record(crate::config::ProviderIdentity {
6854 provider,
6855 key: key.into(),
6856 exact_id,
6857 migrated_legacy_ollama_cloud_route: false,
6858 legacy_root_custom_generation: None,
6859 });
6860 }
6861
6862 pub(crate) fn set_provider_identity_record(
6863 &mut self,
6864 identity: crate::config::ProviderIdentity,
6865 ) {
6866 self.api_provider = identity.provider;
6867 self.provider_identity = Some(identity);
6868 }
6869
6870 pub(crate) fn admitted_provider_identity(
6871 &self,
6872 ) -> Result<&crate::config::ProviderIdentity, String> {
6873 self.provider_identity.as_ref().filter(|identity| identity.provider == self.api_provider)
6874 .ok_or_else(|| "The active provider route was not admitted; repair its configuration before running a request.".to_string())
6875 }
6876
6877 pub fn accepts_custom_model_ids(&self) -> bool {
6878 self.model_ids_passthrough
6879 || crate::config::provider_passes_model_through(self.api_provider)
6880 }
6881
6882 pub(crate) fn apply_provider_switch_reasoning_effort(
6883 &mut self,
6884 provider: ProviderKind,
6885 base_url: &str,
6886 model_override: Option<&str>,
6887 ) {
6888 let wire_model = model_override.unwrap_or(&self.model);
6889 let inferred = model_override.and_then(|model| {
6890 crate::config::legacy_deepseek_alias_effort_for_route(provider, base_url, model)
6891 });
6892 self.reasoning_effort = if let Some(requested) = self.reasoning_effort_preference {
6893 requested.normalize_for_route(provider, base_url, wire_model)
6894 } else if let Some(effort) = inferred {
6895 ReasoningEffort::from_setting(effort)
6896 .normalize_for_route(provider, base_url, wire_model)
6897 } else if let Some(default) = ReasoningEffort::catalog_default(provider, wire_model) {
6898 default
6899 } else {
6900 self.reasoning_effort
6901 .normalize_for_route(provider, base_url, wire_model)
6902 };
6903 self.invalidate_route_receipts_for_reasoning_change();
6904 }
6905
6906 pub fn effective_model_for_budget(&self) -> &str {
6907 if self.auto_model {
6908 return self
6909 .last_effective_model
6910 .as_deref()
6911 .filter(|model| *model != "auto")
6912 .unwrap_or(DEFAULT_TEXT_MODEL);
6913 }
6914 &self.model
6915 }
6916
6917 pub fn model_display_label(&self) -> String {
6918 if self.auto_model {
6919 if let Some(effective) = self.last_effective_model.as_deref()
6920 && effective != "auto"
6921 {
6922 return format!("auto: {effective}");
6923 }
6924 return "auto".to_string();
6925 }
6926 self.model.clone()
6927 }
6928
6929 /// Provider/model identity used by the in-flight or most recent request.
6930 /// This is the display contract for auto routing and must match billing.
6931 #[must_use]
6932 pub fn effective_route_display(&self) -> (ProviderKind, String) {
6933 if let Some((provider, model, _)) = self.pending_turn_route.as_ref() {
6934 return (*provider, model.clone());
6935 }
6936 if self.auto_model
6937 && let (Some(provider), Some(model)) = (
6938 self.last_effective_provider,
6939 self.last_effective_model.as_ref(),
6940 )
6941 {
6942 return (provider, model.clone());
6943 }
6944 (self.api_provider, self.model_display_label())
6945 }
6946
6947 /// Exact non-secret route label for user-visible status surfaces.
6948 #[must_use]
6949 pub fn effective_route_identity_display(&self) -> (String, String) {
6950 let (provider, model) = self.effective_route_display();
6951 let identity = if provider == ProviderKind::Custom {
6952 if self.pending_turn_route.is_none() && self.auto_model {
6953 self.last_effective_provider_identity
6954 .as_deref()
6955 .unwrap_or_else(|| self.provider_identity_for_persistence())
6956 } else {
6957 self.provider_identity_for_persistence()
6958 }
6959 } else {
6960 provider.provider().display_name()
6961 };
6962 (identity.to_string(), model)
6963 }
6964
6965 fn effective_reasoning_effort_for_active_route(
6966 &self,
6967 requested: ReasoningEffort,
6968 ) -> EffectiveReasoningEffort {
6969 let route_truth = self.active_reasoning_route_truth();
6970 let auto_route_has_receipt = self
6971 .active_turn
6972 .as_ref()
6973 .and_then(|turn| turn.route.as_ref())
6974 .is_some_and(|route| route.receipt.is_some());
6975 if self.auto_model
6976 && !auto_route_has_receipt
6977 && self.last_auto_route_receipt.is_some()
6978 && requested == self.reasoning_effort
6979 && let Some(effective) = self.last_effective_reasoning_effort
6980 {
6981 // Once a concrete Auto route has been accepted, its normalized
6982 // tier remains the display authority until the model or requested
6983 // effort changes. The configured classifier route is not evidence
6984 // of what the completed turn received.
6985 return effective;
6986 }
6987 if requested == self.reasoning_effort
6988 && requested == ReasoningEffort::Auto
6989 && let Some(effective) = self.last_effective_reasoning_effort
6990 {
6991 // The accepted route receipt is already the strongest available
6992 // truth. Preserve enabled-but-untiered and unavailable states
6993 // instead of forcing them through the tier-only projection.
6994 return effective;
6995 }
6996 let effective = if requested == ReasoningEffort::Auto {
6997 ReasoningEffort::Auto
6998 } else if self.auto_model && !auto_route_has_receipt {
6999 // The configured provider is only the classifier's starting
7000 // point, not the route that will receive the request.
7001 requested
7002 } else if let Some((provider, _, base_url, model)) = route_truth {
7003 requested.normalize_for_route(provider, base_url, model)
7004 } else {
7005 requested.normalize_for_route(
7006 self.api_provider,
7007 &self.active_route_base_url,
7008 &self.model,
7009 )
7010 };
7011
7012 // Prefer the immutable installed-client receipt while a turn is live.
7013 // If it is unavailable, only use the configured route when no pending
7014 // or active foreign route could make that identity stale.
7015 if let Some((provider, _, base_url, model)) = route_truth {
7016 if let Some(constrained) = crate::work_graph::constrained_effective_reasoning_for_route(
7017 requested.into(),
7018 provider,
7019 base_url,
7020 model,
7021 ) {
7022 return constrained.into();
7023 }
7024 } else if self.active_turn.as_ref().is_some_and(|turn| {
7025 turn.route.as_ref().is_some_and(|route| {
7026 matches!(
7027 route.provider,
7028 ProviderKind::Zai
7029 | ProviderKind::Minimax
7030 | ProviderKind::MinimaxAnthropic
7031 | ProviderKind::Custom
7032 ) && route.receipt.is_none()
7033 })
7034 }) || self
7035 .pending_turn_route
7036 .as_ref()
7037 .is_some_and(|(provider, _, _)| {
7038 matches!(
7039 provider,
7040 ProviderKind::Zai
7041 | ProviderKind::Minimax
7042 | ProviderKind::MinimaxAnthropic
7043 | ProviderKind::Custom
7044 )
7045 })
7046 {
7047 // A route without its immutable endpoint receipt cannot prove
7048 // first-party semantics from provider/model identity alone.
7049 return EffectiveReasoningEffort::Unavailable;
7050 }
7051 EffectiveReasoningEffort::Tier(effective)
7052 }
7053
7054 fn active_reasoning_route_truth(&self) -> Option<(ProviderKind, &str, &str, &str)> {
7055 if let Some(route) = self
7056 .active_turn
7057 .as_ref()
7058 .and_then(|turn| turn.route.as_ref())
7059 {
7060 route.receipt.as_ref().map(|receipt| {
7061 (
7062 receipt.provider(),
7063 receipt.provider_identity(),
7064 receipt.endpoint_identity(),
7065 receipt.wire_model(),
7066 )
7067 })
7068 } else if self.pending_turn_route.is_none() {
7069 Some((
7070 self.api_provider,
7071 self.provider_identity_for_persistence(),
7072 self.active_route_base_url.as_str(),
7073 self.model.as_str(),
7074 ))
7075 } else {
7076 None
7077 }
7078 }
7079
7080 fn reasoning_effort_resolution_label(
7081 requested: ReasoningEffort,
7082 effective: EffectiveReasoningEffort,
7083 provider: ProviderKind,
7084 ) -> String {
7085 match effective {
7086 EffectiveReasoningEffort::Tier(effective) => {
7087 if requested == effective {
7088 return effective.display_label_for_provider(provider).to_string();
7089 }
7090 let effective = effective.display_label_for_provider(provider);
7091 if requested == ReasoningEffort::Auto {
7092 format!("auto: {effective}")
7093 } else {
7094 format!("{}→{effective}", requested.short_label())
7095 }
7096 }
7097 EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => format!(
7098 "{}→thinking enabled; granularity unavailable",
7099 requested.short_label()
7100 ),
7101 EffectiveReasoningEffort::Unavailable => {
7102 format!("{}→effective unavailable", requested.short_label())
7103 }
7104 }
7105 }
7106
7107 pub fn reasoning_effort_display_label(&self) -> String {
7108 let requested = self.reasoning_effort;
7109 let effective = self.effective_reasoning_effort_for_active_route(requested);
7110 Self::reasoning_effort_resolution_label(requested, effective, self.api_provider)
7111 }
7112
7113 /// The effort label the metrics line's route segment may state: the
7114 /// resolution label when the route can prove an effective tier (or an
7115 /// enabled-but-untiered toggle), `None` when it cannot (#5950). A custom
7116 /// OpenAI-compatible route with no endpoint receipt is the usual `None`;
7117 /// printing `high→effective unavailable` there was a placeholder that
7118 /// could never resolve, so the row omits the field instead. `/status`
7119 /// and the effort cycle message still state the unavailable case in
7120 /// full via [`Self::reasoning_effort_display_label`].
7121 #[must_use]
7122 pub(crate) fn provable_reasoning_effort_label(&self) -> Option<String> {
7123 (self.effective_reasoning_effort_for_active_route(self.reasoning_effort)
7124 != EffectiveReasoningEffort::Unavailable)
7125 .then(|| self.reasoning_effort_display_label())
7126 }
7127
7128 /// Return the concrete provider/model route whose current prompt may be
7129 /// inspected or replayed.
7130 ///
7131 /// For a fixed selection, the active route is authoritative. For Auto,
7132 /// `self.model` is only the selector sentinel, so the latest completed
7133 /// turn supplies provider/model/endpoint truth. A restored Auto session
7134 /// retains provider/model but not a raw endpoint; warmup may re-resolve
7135 /// that route from live config, while inspect fails honestly until a new
7136 /// turn captures the endpoint.
7137 #[must_use]
7138 pub(crate) fn cache_replay_target(&self) -> Option<CacheReplayTarget> {
7139 if !self.auto_model {
7140 let model = self.model.trim();
7141 if model.is_empty() || model.eq_ignore_ascii_case("auto") {
7142 return None;
7143 }
7144 let base_url = (!self.active_route_base_url.trim().is_empty())
7145 .then(|| self.active_route_base_url.clone());
7146 return Some(CacheReplayTarget {
7147 provider: self.api_provider,
7148 provider_identity: self.provider_identity_for_persistence().to_string(),
7149 provider_id: self.provider_id_for_persistence().map(str::to_string),
7150 model: model.to_string(),
7151 base_url,
7152 });
7153 }
7154
7155 let provider = self.last_effective_provider?;
7156 let model = self.last_effective_model.as_deref()?.trim();
7157 if model.is_empty() || model.eq_ignore_ascii_case("auto") {
7158 return None;
7159 }
7160 let provider_identity = self
7161 .last_effective_provider_identity
7162 .as_deref()
7163 .map(str::trim)
7164 .filter(|identity| !identity.is_empty())
7165 .map(str::to_string)
7166 .or_else(|| {
7167 (provider != ProviderKind::Custom).then(|| provider.as_str().to_string())
7168 })?;
7169 let provider_id = if provider != ProviderKind::Custom {
7170 Some(provider.as_str().to_string())
7171 } else if !provider_identity.eq_ignore_ascii_case(ProviderKind::Custom.as_str()) {
7172 Some(provider_identity.clone())
7173 } else if self.api_provider == ProviderKind::Custom
7174 && self
7175 .provider_identity_for_persistence()
7176 .eq_ignore_ascii_case(&provider_identity)
7177 {
7178 self.provider_id_for_persistence().map(str::to_string)
7179 } else {
7180 None
7181 };
7182
7183 let latest_matches_route = self
7184 .session
7185 .turn_cache_history
7186 .back()
7187 .is_some_and(|record| {
7188 record.auto_model
7189 && record.provider == Some(provider)
7190 && record
7191 .model
7192 .as_deref()
7193 .is_some_and(|record_model| record_model.eq_ignore_ascii_case(model))
7194 && record
7195 .provider_identity
7196 .as_deref()
7197 .map(str::trim)
7198 .filter(|identity| !identity.is_empty())
7199 .map_or(provider != ProviderKind::Custom, |identity| {
7200 identity == provider_identity
7201 })
7202 });
7203 let warmup_base_url = self
7204 .session
7205 .last_warmup_key
7206 .as_ref()
7207 .filter(|key| {
7208 key.provider == provider_identity
7209 && key.model.eq_ignore_ascii_case(model)
7210 && !key.base_url.trim().is_empty()
7211 })
7212 .map(|key| key.base_url.clone());
7213 let base_url = latest_matches_route
7214 .then(|| self.session.last_base_url.clone())
7215 .flatten()
7216 .or(warmup_base_url)
7217 .filter(|base_url| !base_url.trim().is_empty());
7218
7219 Some(CacheReplayTarget {
7220 provider,
7221 provider_identity,
7222 provider_id,
7223 model: model.to_string(),
7224 base_url,
7225 })
7226 }
7227
7228 /// Provider-facing effort used when replaying the current prompt for cache
7229 /// inspection or warmup on one exact route.
7230 #[must_use]
7231 pub(crate) fn reasoning_effort_api_value_for_replay(
7232 &self,
7233 provider: ProviderKind,
7234 base_url: &str,
7235 model: &str,
7236 ) -> Option<&'static str> {
7237 let requested = if self.reasoning_effort == ReasoningEffort::Auto {
7238 self.last_effective_reasoning_effort?
7239 .request_tier_for_replay()?
7240 } else {
7241 self.reasoning_effort
7242 };
7243 requested.api_value_for_route(provider, base_url, model)
7244 }
7245
7246 pub fn compaction_config(&self) -> CompactionConfig {
7247 let mut config = self.compaction_config_for_route(
7248 self.api_provider,
7249 self.effective_model_for_budget(),
7250 self.active_route_limits,
7251 );
7252 // These cached fields are the active-route compatibility authority and
7253 // are updated together by `update_model_compaction_budget`. Commands
7254 // and embedders may also adjust them directly between route updates.
7255 config.enabled = self.auto_compact;
7256 config.token_threshold = self.compact_threshold;
7257 config
7258 }
7259
7260 /// Build compaction policy from one already-resolved provider route.
7261 ///
7262 /// Auto routing can select a provider/model whose context limits differ
7263 /// from the route currently displayed by the app. Callers dispatching that
7264 /// turn must derive every compaction input from the selected descriptor,
7265 /// not from the previous route cached in `App`.
7266 pub(crate) fn compaction_config_for_route(
7267 &self,
7268 provider: ProviderKind,
7269 model: &str,
7270 route_limits: Option<RouteLimits>,
7271 ) -> CompactionConfig {
7272 CompactionConfig {
7273 enabled: if self.auto_compact_user_configured {
7274 self.auto_compact
7275 } else {
7276 crate::route_budget::auto_compact_default_for_route(provider, model, route_limits)
7277 },
7278 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
7279 provider,
7280 model,
7281 route_limits,
7282 self.auto_compact_threshold_percent,
7283 ),
7284 model: model.to_string(),
7285 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
7286 provider,
7287 model,
7288 route_limits,
7289 )),
7290 summary_instructions: self.compaction_summary_instructions.clone(),
7291 retained_user_message_tokens: self.compaction_retained_user_message_tokens,
7292 ..Default::default()
7293 }
7294 }
7295
7296 pub fn fallback_chain_entries(&self) -> Vec<(usize, ProviderKind, bool)> {
7297 let Some(chain) = &self.provider_chain else {
7298 return Vec::new();
7299 };
7300 let position = chain.position();
7301 chain
7302 .providers()
7303 .iter()
7304 .enumerate()
7305 .map(|(index, provider)| (index, *provider, index == position))
7306 .collect()
7307 }
7308
7309 pub fn fallback_chain_position(&self) -> Option<usize> {
7310 self.provider_chain.as_ref().map(ProviderChain::position)
7311 }
7312
7313 pub fn fallback_chain_len(&self) -> usize {
7314 self.provider_chain
7315 .as_ref()
7316 .map_or(0, |chain| chain.providers().len())
7317 }
7318
7319 /// Whether a fallback chain entry can serve a turn right now (#2574).
7320 ///
7321 /// Mirrors the provider picker's eligibility: hosted providers need a key
7322 /// (`has_api_key_for`, captured into `provider_readiness` at startup) while
7323 /// self-hosted providers (Ollama/vLLM/SGLang) are always ready. Providers
7324 /// absent from the captured identity snapshot remain unready; a display
7325 /// kind cannot establish credentials for a missing or custom route.
7326 fn fallback_provider_is_ready(&self, provider: ProviderKind) -> bool {
7327 self.provider_readiness
7328 .iter()
7329 .find_map(|(candidate, ready)| {
7330 (candidate.provider == provider && candidate.key.as_str() == provider.as_str())
7331 .then_some(*ready)
7332 })
7333 .unwrap_or(false)
7334 }
7335
7336 /// Advance to the next *eligible* provider in the fallback chain (#2574).
7337 ///
7338 /// Walks the chain from the current position, skipping entries that are not
7339 /// ready (hosted providers missing auth) and recording a clear note for each
7340 /// skip. Local providers are always eligible. Returns the first ready
7341 /// provider, or `None` (with an exhaustion reason) when every remaining entry
7342 /// is unready or the end of the chain is reached. `ProviderChain::advance`
7343 /// stays pure — the readiness filtering lives here at the App level.
7344 ///
7345 /// Note: auth-rejection (401) failures never reach this path; the caller
7346 /// excludes them from fallback so a bad key does not silently rotate
7347 /// providers (see `apply_engine_error_to_app`).
7348 ///
7349 /// Local/private policy (#2574): when the chain's primary provider is a
7350 /// self-hosted / local runtime, cloud candidates are skipped with a clear
7351 /// note so a local/private route never silently falls back out to a hosted
7352 /// provider. Self-hosted siblings remain eligible. The policy is anchored
7353 /// to the original primary; a cloud primary may still hop through a local
7354 /// runtime and then back to another cloud fallback.
7355 pub fn advance_fallback(&mut self, reason: impl Into<String>) -> Option<ProviderKind> {
7356 let reason = reason.into();
7357 self.provider_chain.as_ref()?;
7358
7359 let origin_is_local = self
7360 .provider_chain
7361 .as_ref()
7362 .and_then(|chain| chain.providers().first().copied())
7363 .is_some_and(|kind| {
7364 kind.provider().credential_help().acquisition
7365 == codewhale_config::provider::CredentialAcquisition::LocalOptional
7366 });
7367
7368 let mut skip_notes: Vec<String> = Vec::new();
7369 let mut chosen: Option<ProviderKind> = None;
7370 while let Some(next_kind) = self
7371 .provider_chain
7372 .as_mut()
7373 .and_then(ProviderChain::advance)
7374 {
7375 let candidate = next_kind;
7376 if origin_is_local
7377 && candidate.provider().credential_help().acquisition
7378 != codewhale_config::provider::CredentialAcquisition::LocalOptional
7379 {
7380 skip_notes.push(format!(
7381 "skipped {}: local/private policy (no local->cloud fallback)",
7382 candidate.as_str()
7383 ));
7384 continue;
7385 }
7386 if self.fallback_provider_is_ready(candidate) {
7387 chosen = Some(candidate);
7388 break;
7389 }
7390 skip_notes.push(format!("skipped {}: needs auth", candidate.as_str()));
7391 }
7392
7393 let skipped = if skip_notes.is_empty() {
7394 String::new()
7395 } else {
7396 format!(" ({})", skip_notes.join("; "))
7397 };
7398
7399 let Some(next_provider) = chosen else {
7400 let total = self
7401 .provider_chain
7402 .as_ref()
7403 .map_or(0, |chain| chain.providers().len());
7404 self.last_fallback_reason = Some(format!(
7405 "Fallback chain exhausted after {total} provider(s): {reason}{skipped}"
7406 ));
7407 return None;
7408 };
7409
7410 let identity = self
7411 .provider_readiness
7412 .iter()
7413 .find(|(identity, ready)| {
7414 *ready
7415 && identity.provider == next_provider
7416 && identity.key.as_str() == next_provider.as_str()
7417 })?
7418 .0
7419 .clone();
7420 self.set_provider_identity_record(identity);
7421 self.last_fallback_reason = Some(format!(
7422 "Fell back to {} after recoverable provider error: {reason}{skipped}",
7423 next_provider.as_str()
7424 ));
7425 Some(next_provider)
7426 }
7427
7428 pub fn is_fallback_active(&self) -> bool {
7429 self.provider_chain
7430 .as_ref()
7431 .is_some_and(ProviderChain::is_fallback_active)
7432 }
7433 }
7434
7435 pub fn media_attachment_reference(kind: &str, path: &Path, description: Option<&str>) -> String {
7436 match description {
7437 Some(description) if !description.trim().is_empty() => {
7438 format!(
7439 "[Attached {kind}: {} at {}]",
7440 description.trim(),
7441 path.display()
7442 )
7443 }
7444 _ => format!("[Attached {kind}: {}]", path.display()),
7445 }
7446 }
7447
7448 #[cfg(test)]
7449 mod tests;
7450
7450 lines RUST