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