返回 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::time::{Duration, Instant};
8
9 use chrono::{DateTime, Utc};
10 use ratatui::layout::Rect;
11 use ratatui::style::Color;
12 use serde::{Deserialize, Serialize};
13 use serde_json::Value;
14
15 use codewhale_config::{ProviderChain, route::RouteLimits};
16
17 use crate::artifacts::ArtifactRecord;
18 use crate::client::{CacheWarmupKey, PromptInspection};
19 use crate::compaction::CompactionConfig;
20 use crate::config::{
21 ApiProvider, ApprovalPolicyControl, Config, DEFAULT_TEXT_MODEL, has_api_key, has_api_key_for,
22 };
23 use crate::config_ui::ConfigUiMode;
24 use crate::core::authority::{ModeSessionPrefs, base_policy_for_mode};
25 use crate::core::events::TurnRoute;
26 use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
27 use crate::localization::{Locale, MessageId, resolve_locale, tr};
28 use crate::models::{Message, SystemPrompt, Tool};
29 use crate::palette::{self, UiTheme};
30 use crate::pricing::{CostCurrency, CostEstimate};
31 use crate::resource_telemetry::TokenThroughput;
32 use crate::session_manager::{SessionContextReference, SessionMetadata, SessionWorkState};
33 use crate::settings::{InlineDiffMode, Settings};
34 use crate::tools::plan::{PlanState, SharedPlanState, new_shared_plan_state};
35 use crate::tools::shell::new_shared_shell_manager;
36 use crate::tools::spec::RuntimeToolServices;
37 use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult};
38 use crate::tools::todo::{SharedTodoList, TodoList, new_shared_todo_list};
39 use crate::tui::active_cell::ActiveCell;
40 use crate::tui::approval::ApprovalMode;
41 use crate::tui::clipboard::{ClipboardContent, ClipboardHandler};
42 use crate::tui::file_mention::ContextReference;
43 use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
44 use crate::tui::hotbar::HotbarActionRegistry;
45 use crate::tui::motion::MotionPolicy;
46 use crate::tui::paste_burst::{FlushResult, PasteBurst};
47 use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll};
48 use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection};
49 use crate::tui::sidebar::SidebarWorkSummary;
50 use crate::tui::streaming::StreamingState;
51 use crate::tui::transcript::TranscriptViewCache;
52 use crate::tui::views::ViewStack;
53
54 mod composer;
55 mod init;
56 mod status;
57 mod types;
58
59 pub use composer::ComposerHistorySearch;
60 pub(crate) use composer::{InputHistoryDraft, char_count};
61 #[cfg(test)]
62 pub(crate) use composer::{
63 MAX_SUBMITTED_INPUT_CHARS, next_grapheme_boundary, prev_grapheme_boundary,
64 };
65 pub use status::{StatusToast, StatusToastLevel};
66 pub use types::{
67 AppAction, AppMode, AutomationAction, ComposerDensity, ComposerSubmitAction,
68 ComposerSubmitChord, InitialInput, McpUiAction, QueuedMessage, ReasoningEffort,
69 SettingSelection, ShellJobAction, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
70 ToolCollapseMode, ToolDetailRecord, TranscriptSpacing, TuiOptions, VimMode,
71 };
72 pub(crate) use types::{CacheReplayTarget, EffectiveReasoningEffort};
73
74 // === Types ===
75
76 /// Lifecycle identity retained until the matching `TurnComplete` arrives.
77 ///
78 /// This survives local cancellation clearing the visible runtime status, so
79 /// observer records still carry a stable id, start time, and effective route.
80 #[derive(Debug, Clone, PartialEq, Eq)]
81 pub struct ActiveTurnMetadata {
82 pub turn_id: String,
83 pub created_at: DateTime<Utc>,
84 pub route: Option<TurnRoute>,
85 /// Auto decision metadata captured with this exact authoritative route.
86 pub auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
87 /// Non-secret proof of the exact endpoint + credential this turn launched
88 /// against, adopted at `TurnStarted` from the engine's route receipt — not
89 /// re-resolved from mutable config. Only populated for routes that can
90 /// produce a follow-up prompt suggestion; see
91 /// [`crate::tui::prompt_suggestion::capture_route_authority`].
92 pub suggestion_authority: Option<crate::tui::prompt_suggestion::SuggestionRouteAuthority>,
93 }
94
95 /// Per-message context estimates used by the render-time context meter.
96 /// Messages are append-only in the steady state; only the streaming tail is
97 /// mutable, so the tail is refreshed while older entries remain cached.
98 #[derive(Debug, Default)]
99 pub(crate) struct ContextTokenCache {
100 pub(crate) message_tokens: Vec<usize>,
101 }
102
103 impl ContextTokenCache {
104 pub(crate) fn clear(&mut self) {
105 self.message_tokens.clear();
106 }
107 }
108
109 /// State machine for onboarding new users.
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 pub enum OnboardingState {
112 Welcome,
113 /// Pick the UI locale before any other config decisions (#566).
114 /// Defaults to auto-detection from `LC_ALL` / `LANG`; explicit picks
115 /// land in the persisted settings.toml via `Settings::set("locale", …)`.
116 Language,
117 /// "Make it yours" — pick a theme right after language (#3937).
118 ///
119 /// This is a one-key default step: it reuses the `/theme` picker, so the
120 /// preview is live and transactional (Enter persists, Esc restores the
121 /// theme the session started with) and there is no second theme registry.
122 Appearance,
123 Provider,
124 TrustDirectory,
125 MentalModels,
126 Tips,
127 None,
128 }
129
130 pub(crate) fn resolve_skills_dir(
131 workspace: &Path,
132 global_skills_dir: &Path,
133 config: &Config,
134 ) -> PathBuf {
135 if config.skills_config().scan_codewhale_only() {
136 if config.skills_dir.is_some() {
137 return global_skills_dir.to_path_buf();
138 }
139 if let Some(codewhale_skills_dir) = crate::skills::codewhale_workspace_skills_dir(workspace)
140 {
141 return codewhale_skills_dir;
142 }
143 return global_skills_dir.to_path_buf();
144 }
145
146 let agents_skills_dir = workspace.join(".agents").join("skills");
147 if agents_skills_dir.exists() {
148 return agents_skills_dir;
149 }
150
151 let local_skills_dir = workspace.join("skills");
152 if local_skills_dir.exists() {
153 return local_skills_dir;
154 }
155
156 if config.skills_dir.is_none()
157 && let Some(global_agents) = crate::skills::agents_global_skills_dir()
158 && global_agents.exists()
159 {
160 return global_agents;
161 }
162
163 global_skills_dir.to_path_buf()
164 }
165
166 pub(crate) fn looks_like_slash_command_input(input: &str) -> bool {
167 let trimmed = input.trim_start();
168 // `$skillname` at the start of input is treated like a slash command so the
169 // skill-completion menu appears.
170 let Some(rest) = trimmed
171 .strip_prefix('/')
172 .or_else(|| trimmed.strip_prefix('$'))
173 else {
174 return false;
175 };
176 if rest.chars().next().is_some_and(|ch| ch.is_whitespace()) {
177 return false;
178 }
179 let Some(command) = rest.split_whitespace().next() else {
180 return rest.is_empty();
181 };
182
183 !command.contains('/')
184 }
185
186 pub(crate) fn shell_command_from_bang_input(input: &str) -> Result<Option<&str>, &'static str> {
187 let Some(rest) = input.trim_start().strip_prefix('!') else {
188 return Ok(None);
189 };
190 let command = rest.trim();
191 if command.is_empty() {
192 return Err("Usage: ! <shell command>");
193 }
194
195 Ok(Some(command))
196 }
197
198 pub(crate) fn is_stop_word(input: &str, stop_words: &[String]) -> Option<String> {
199 let trimmed = input.trim();
200 let after_prefix = trimmed
201 .strip_prefix('+')
202 .or_else(|| trimmed.strip_prefix('!'))
203 .map_or(trimmed, str::trim_start);
204 let word = after_prefix.trim_end_matches(|c: char| c.is_ascii_punctuation());
205 if word.is_empty() || word.chars().any(char::is_whitespace) {
206 return None;
207 }
208 let lower = word.to_ascii_lowercase();
209 stop_words
210 .iter()
211 .find(|stop_word| stop_word.to_ascii_lowercase() == lower)
212 .cloned()
213 }
214
215 fn initial_onboarding_state(
216 skip_onboarding: bool,
217 was_onboarded: bool,
218 needs_api_key: bool,
219 needs_workspace_trust: bool,
220 ) -> OnboardingState {
221 if skip_onboarding || (was_onboarded && !needs_api_key && !needs_workspace_trust) {
222 return OnboardingState::None;
223 }
224
225 if was_onboarded && needs_api_key {
226 // Missing-key recovery uses the canonical provider picker so it can
227 // preserve the configured provider, endpoint, and model route before
228 // asking for a replacement secret.
229 OnboardingState::Provider
230 } else if was_onboarded && needs_workspace_trust {
231 OnboardingState::TrustDirectory
232 } else {
233 OnboardingState::Welcome
234 }
235 }
236
237 fn onboarding_is_workspace_trust_gate(
238 skip_onboarding: bool,
239 was_onboarded: bool,
240 needs_api_key: bool,
241 needs_workspace_trust: bool,
242 ) -> bool {
243 !skip_onboarding && was_onboarded && !needs_api_key && needs_workspace_trust
244 }
245
246 /// Resolve the launch onboarding state and the missing-key-recovery flag in one
247 /// place. When the active xAI OAuth credential is missing (`xai_oauth_needs_reauth`),
248 /// the user already chose xAI and only needs to re-authenticate it — so the
249 /// generic provider picker must NOT reopen (returns `OnboardingState::None` and
250 /// `missing_key_recovery = false`); the caller surfaces a re-auth message (#5032).
251 fn launch_onboarding_decision(
252 skip_onboarding: bool,
253 was_onboarded: bool,
254 needs_api_key: bool,
255 needs_workspace_trust: bool,
256 xai_oauth_needs_reauth: bool,
257 ) -> (OnboardingState, bool) {
258 let onboarding = if xai_oauth_needs_reauth && was_onboarded {
259 OnboardingState::None
260 } else {
261 initial_onboarding_state(
262 skip_onboarding,
263 was_onboarded,
264 needs_api_key,
265 needs_workspace_trust,
266 )
267 };
268 let missing_key_recovery =
269 !skip_onboarding && was_onboarded && needs_api_key && !xai_oauth_needs_reauth;
270 (onboarding, missing_key_recovery)
271 }
272
273 /// One row in the per-turn cache-telemetry ring (`/cache` debug surface, #263).
274 #[derive(Debug, Clone)]
275 pub struct TurnCacheRecord {
276 /// API provider used for the turn. This is recorded so cache misses can be
277 /// correlated with provider/model route changes.
278 pub provider: Option<ApiProvider>,
279 /// Exact non-secret configured route key. This distinguishes named custom
280 /// providers which all share [`ApiProvider::Custom`].
281 pub provider_identity: Option<String>,
282 /// Concrete model used for the turn. For auto-model turns this is the
283 /// routed model, not the literal `auto` setting.
284 pub model: Option<String>,
285 /// Whether the route came from the auto-model selector.
286 pub auto_model: bool,
287 /// Provider-reported total input tokens for the turn (cache-hit +
288 /// cache-miss + uncategorized). Useful for sanity-checking that hits +
289 /// misses sum back to roughly the prompt size.
290 pub input_tokens: u32,
291 /// Provider-reported output tokens.
292 pub output_tokens: u32,
293 /// `prompt_cache_hit_tokens` from DeepSeek's usage payload. `None` when
294 /// the model in use does not report cache telemetry (see
295 /// `Capabilities::cache_telemetry_supported`).
296 pub cache_hit_tokens: Option<u32>,
297 /// `prompt_cache_miss_tokens`. `None` when the provider did not report it
298 /// — in that case the `/cache` formatter infers the miss as
299 /// `input_tokens − cache_hit_tokens`.
300 pub cache_miss_tokens: Option<u32>,
301 /// Cache-creation tokens (`cache_creation_input_tokens` on Anthropic-style
302 /// payloads). Billed at a premium where the provider publishes one, so
303 /// they are recorded as their own class rather than folded into misses.
304 pub cache_write_tokens: Option<u32>,
305 /// Reasoning tokens the provider reported. **Informational only**: every
306 /// provider counts these inside `output_tokens`, so they are never added
307 /// to billable output.
308 pub reasoning_tokens: Option<u32>,
309 /// The turn's cost with its provenance and per-class completeness, taken
310 /// from the same call that fed the session total. `None` for records made
311 /// without route provenance (legacy rows, synthetic test rows).
312 pub cost_audit: Option<crate::pricing::TurnCostAudit>,
313 /// Approximate tokens spent re-sending prior `reasoning_content` on
314 /// V4-thinking tool-calling turns (chars/3 heuristic). Helps separate
315 /// cache misses caused by reasoning-replay churn from misses caused by
316 /// real prefix instability.
317 pub reasoning_replay_tokens: Option<u32>,
318 /// Local timestamp the turn telemetry was recorded.
319 pub recorded_at: Instant,
320 }
321
322 /// Browsing context captured when the `/model` picker is dismissed (#4109).
323 /// Plain data so `App` does not depend on the picker's internal view enum.
324 #[derive(Debug, Clone, PartialEq, Eq)]
325 pub struct ModelPickerMemory {
326 /// True when the user left the picker in the full-catalog view
327 /// (`A` toggle), false for the configured-only default view.
328 ///
329 /// Kept for backward compatibility with older dismiss events; prefer
330 /// [`Self::view`] when present (#4115).
331 pub catalog_view: bool,
332 /// Named catalog view left open (`configured` / `catalog` / `recent` /
333 /// `coding` / `cheap` / `long_context`). When `None`, [`Self::catalog_view`]
334 /// is the fallback.
335 pub view: Option<String>,
336 /// Model row id highlighted at dismissal, if it was a real row.
337 pub selected_row_id: Option<String>,
338 }
339
340 /// Browsing context captured when the `/provider` picker is dismissed.
341 /// Mirrors [`ModelPickerMemory`] so reopen restores view + highlight.
342 #[derive(Debug, Clone, PartialEq, Eq)]
343 pub struct ProviderPickerMemory {
344 /// True when the user left the picker in the full-catalog view
345 /// (`A` toggle), false for the configured-only default view.
346 pub catalog_view: bool,
347 /// Provider id highlighted at dismissal, if it was a real row.
348 pub selected_provider_id: Option<String>,
349 }
350
351 /// Bounded status vocabulary for the per-agent current-activity projection.
352 ///
353 /// This is presentation state derived from structured worker/mailbox events;
354 /// renderers map these variants to labels but never infer them from strings.
355 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
356 pub enum AgentCurrentActivityStatus {
357 Queued,
358 Starting,
359 Running,
360 ModelWait,
361 RunningTool,
362 Waiting,
363 Done,
364 Failed,
365 Canceled,
366 Interrupted,
367 }
368
369 impl From<AgentWorkerStatus> for AgentCurrentActivityStatus {
370 fn from(status: AgentWorkerStatus) -> Self {
371 match status {
372 AgentWorkerStatus::Queued => Self::Queued,
373 AgentWorkerStatus::Starting => Self::Starting,
374 AgentWorkerStatus::Running => Self::Running,
375 AgentWorkerStatus::WaitingForUser => Self::Waiting,
376 AgentWorkerStatus::ModelWait => Self::ModelWait,
377 AgentWorkerStatus::RunningTool => Self::RunningTool,
378 AgentWorkerStatus::Completed => Self::Done,
379 AgentWorkerStatus::Failed => Self::Failed,
380 AgentWorkerStatus::Cancelled => Self::Canceled,
381 AgentWorkerStatus::Interrupted => Self::Interrupted,
382 }
383 }
384 }
385
386 #[derive(Debug, Clone, PartialEq, Eq)]
387 pub struct AgentCurrentActivity {
388 pub status: AgentCurrentActivityStatus,
389 /// Safe bounded context, never a raw child transcript or tool result.
390 pub detail: Option<String>,
391 /// Safe display name for the one tool currently executing.
392 pub current_tool: Option<String>,
393 pub step: Option<u32>,
394 }
395
396 impl AgentCurrentActivity {
397 #[must_use]
398 pub fn bounded(
399 status: AgentCurrentActivityStatus,
400 detail: Option<String>,
401 current_tool: Option<String>,
402 step: Option<u32>,
403 ) -> Self {
404 fn bounded_nonempty(value: Option<String>) -> Option<String> {
405 value
406 .map(|value| bound_agent_activity_text(&value))
407 .filter(|value| !value.trim().is_empty())
408 }
409
410 Self {
411 status,
412 detail: bounded_nonempty(detail),
413 current_tool: bounded_nonempty(current_tool),
414 step,
415 }
416 }
417 }
418
419 /// Convert untrusted child-agent text into a compact UI-safe projection.
420 /// Full transcript artifacts remain the source of truth; only summaries that
421 /// can enter the parent transcript/sidebar pass through this seam.
422 pub(crate) fn bound_agent_activity_text(value: &str) -> String {
423 let mut visible = String::with_capacity(value.len());
424 crate::tui::osc8::strip_ansi_into(value, &mut visible);
425 let redacted = codewhale_config::persistence::redact_secrets(&visible);
426 crate::tui::history::summarize_tool_output(&redacted)
427 }
428
429 /// One bounded, structured tool outcome for the Agent Details projection.
430 ///
431 /// This is populated only from `ToolCallCompleted` mailbox envelopes. It is
432 /// deliberately not inferred from free-form progress text.
433 #[derive(Debug, Clone, PartialEq, Eq)]
434 pub struct AgentRecentAction {
435 pub tool: String,
436 pub step: u32,
437 pub ok: bool,
438 }
439
440 impl AgentRecentAction {
441 #[must_use]
442 pub fn bounded(tool: &str, step: u32, ok: bool) -> Self {
443 Self {
444 tool: bound_agent_activity_text(tool),
445 step,
446 ok,
447 }
448 }
449 }
450
451 pub(crate) const MAX_AGENT_RECENT_ACTIONS: usize = 3;
452
453 #[derive(Debug, Clone, Default, PartialEq, Eq)]
454 pub struct AgentProgressMeta {
455 pub parent_run_id: Option<String>,
456 pub spawn_depth: u32,
457 /// Structured, bounded answer to "what is this agent doing now?".
458 pub current_activity: Option<AgentCurrentActivity>,
459 /// Last tool observed running for this child. Cleared by the matching
460 /// completion envelope so Work never presents a settled tool as live.
461 pub current_tool: Option<String>,
462 /// Successful file mutations observed for this child in this session.
463 pub files_touched: u32,
464 /// At most three tool outcomes observed through structured lifecycle
465 /// envelopes, oldest to newest.
466 pub recent_actions: VecDeque<AgentRecentAction>,
467 /// Effective route facts observed from a real child token-usage envelope.
468 /// These stay absent until the provider actually reports usage.
469 pub resolved_provider: Option<String>,
470 pub resolved_model: Option<String>,
471 /// Tokens this child has *used* (input + output), accumulated across its
472 /// own usage envelopes — the same total the worker budget tracks.
473 /// `None` until the provider actually reports usage: a sub-agent whose
474 /// spend is unknown renders no token figure at all rather than a
475 /// fabricated `0`.
476 pub received_tokens: Option<u64>,
477 /// Unsettled items on this child's own to-do ledger, from the latest
478 /// `WorkState` envelope. `None` until a real list is published — the
479 /// strip never invents a `0 left` chip for agents with no checklist.
480 pub todos_remaining: Option<u32>,
481 }
482
483 /// Per-turn LSP repair-loop summary for the Turn Inspector (#4107).
484 /// Observable state only — no raw diagnostic text or prompt internals.
485 #[derive(Debug, Clone, PartialEq, Eq)]
486 pub struct LspRepairState {
487 pub diagnostics_found: usize,
488 pub files_touched: usize,
489 pub injected: bool,
490 pub repair_attempted: bool,
491 /// "resolved" | "still_failing" | "unknown" | "unavailable"
492 pub latest: &'static str,
493 }
494
495 impl Default for LspRepairState {
496 fn default() -> Self {
497 Self {
498 diagnostics_found: 0,
499 files_touched: 0,
500 injected: false,
501 repair_attempted: false,
502 latest: "unavailable",
503 }
504 }
505 }
506
507 /// Pre-session launch menu state for the underwater shell.
508 ///
509 /// This is deliberately separate from onboarding and from the post-launch
510 /// empty session. It selects real session/worktree actions before the
511 /// transcript and composer become active.
512 #[derive(Debug, Clone, PartialEq, Eq)]
513 pub struct LaunchState {
514 pub visible: bool,
515 pub selected: usize,
516 pub worktree_input: Option<String>,
517 pub status: Option<String>,
518 pub workspace_session_count: usize,
519 pub worktree_available: bool,
520 /// Row hitboxes from the most recent launch render.
521 pub row_areas: Vec<Rect>,
522 }
523
524 impl LaunchState {
525 #[must_use]
526 pub fn new(visible: bool, workspace: &std::path::Path) -> Self {
527 let workspace_session_count = crate::session_manager::SessionManager::default_location()
528 .and_then(|manager| manager.list_sessions())
529 .map(|sessions| {
530 sessions
531 .into_iter()
532 .filter(|session| {
533 crate::session_manager::workspace_scope_matches(
534 &session.workspace,
535 workspace,
536 )
537 })
538 .count()
539 })
540 .unwrap_or(0);
541 let worktree_available = std::process::Command::new("git")
542 .current_dir(workspace)
543 .args(["rev-parse", "--show-toplevel"])
544 .output()
545 .is_ok_and(|output| output.status.success());
546 Self {
547 visible,
548 selected: 0,
549 worktree_input: None,
550 status: None,
551 workspace_session_count,
552 worktree_available,
553 row_areas: Vec::new(),
554 }
555 }
556 }
557
558 /// Cached @-mention completion results to avoid re-walking the filesystem when
559 /// the cursor moves inside the same mention token.
560 #[derive(Debug, Clone)]
561 pub struct MentionCompletionCache {
562 /// Workspace root used for this completion walk.
563 pub workspace: PathBuf,
564 /// Process cwd captured for cwd-relative completion entries.
565 pub cwd: Option<PathBuf>,
566 /// The partial text after `@` that triggered this completion.
567 pub partial: String,
568 /// Candidate limit used for this completion walk.
569 pub limit: usize,
570 /// Workspace depth limit used for this completion walk. Included so live
571 /// config changes invalidate cached popup results.
572 pub walk_depth: usize,
573 /// Completion behavior used for this walk. Included so live config changes
574 /// invalidate cached popup results.
575 pub behavior: String,
576 /// Whether symlink following was enabled for this completion walk.
577 /// Included so live config changes invalidate cached popup results.
578 pub follow_links: bool,
579 /// Cached completion entries.
580 pub entries: Vec<String>,
581 }
582
583 /// Composer input state — grouped fields for the text input area.
584 pub struct ComposerState {
585 /// Current composer text content.
586 pub input: String,
587 /// Cursor position within `input` (in characters).
588 pub cursor_position: usize,
589 /// Single-entry kill buffer for emacs-style `Ctrl+K` cut / `Ctrl+Y` yank.
590 pub kill_buffer: String,
591 pub paste_burst: PasteBurst,
592 /// When a large paste is consolidated at submit time, the file @mention
593 /// is stored here so it can be appended to the submitted text without
594 /// replacing the visible composer content (#3263).
595 pub(crate) pending_paste_reference: Option<String>,
596 /// When composer content is oversized, the full text is stored here
597 /// while `self.input` shows a truncated preview. At submit time the
598 /// full text is restored for model submission (#3263).
599 pub(crate) oversized_paste_full_text: Option<String>,
600 pub input_history: Vec<String>,
601 pub draft_history: VecDeque<String>,
602 pub clear_undo_buffer: Option<String>,
603 pub history_index: Option<usize>,
604 pub(crate) history_navigation_draft: Option<InputHistoryDraft>,
605 pub composer_history_search: Option<ComposerHistorySearch>,
606 pub selected_attachment_index: Option<usize>,
607 pub slash_menu_selected: usize,
608 pub slash_menu_hidden: bool,
609 pub mention_menu_selected: usize,
610 pub mention_menu_hidden: bool,
611 /// Cached @-mention completions to avoid re-walking the filesystem when
612 /// the cursor moves inside the same mention token.
613 pub mention_completion_cache: Option<MentionCompletionCache>,
614 /// Serialized background discovery and its bounded candidate cache. All
615 /// filesystem traversal for composer completions lives behind this owner.
616 pub(crate) mention_discovery: crate::tui::mention_completion::MentionDiscovery,
617 /// Launch directory captured once so rendering a completion popup never
618 /// needs to call `getcwd` on the UI thread.
619 pub(crate) mention_cwd: Option<PathBuf>,
620 /// Whether vim modal editing is enabled for this composer.
621 /// Sourced from `Settings::composer_vim_mode` at startup.
622 pub vim_enabled: bool,
623 /// Current vim editing mode. Only meaningful when `vim_enabled` is true.
624 pub vim_mode: VimMode,
625 /// Pending `d` prefix for the `dd` delete-line operator. Set when the
626 /// user presses `d` in Normal mode; cleared on the next key (either `d`
627 /// to complete `dd`, or any other key to cancel).
628 pub vim_pending_d: bool,
629 /// When set, the cursor is the active end of a text selection and
630 /// `selection_anchor` is the fixed end. Both are char-indexed.
631 /// `None` means no selection is active.
632 pub selection_anchor: Option<usize>,
633 }
634
635 impl Default for ComposerState {
636 fn default() -> Self {
637 Self {
638 input: String::new(),
639 cursor_position: 0,
640 kill_buffer: String::new(),
641 paste_burst: PasteBurst::default(),
642 pending_paste_reference: None,
643 oversized_paste_full_text: None,
644 input_history: Vec::new(),
645 draft_history: VecDeque::new(),
646 clear_undo_buffer: None,
647 history_index: None,
648 history_navigation_draft: None,
649 composer_history_search: None,
650 selected_attachment_index: None,
651 slash_menu_selected: 0,
652 slash_menu_hidden: false,
653 mention_menu_selected: 0,
654 mention_menu_hidden: false,
655 mention_completion_cache: None,
656 mention_discovery: crate::tui::mention_completion::MentionDiscovery::default(),
657 mention_cwd: std::env::current_dir().ok(),
658 vim_enabled: false,
659 vim_mode: VimMode::Normal,
660 vim_pending_d: false,
661 selection_anchor: None,
662 }
663 }
664 }
665
666 /// Viewport/scroll state — fields related to transcript scrolling and caching.
667 pub struct ViewportState {
668 pub transcript_scroll: TranscriptScroll,
669 pub pending_scroll_delta: i32,
670 pub mouse_scroll: MouseScrollState,
671 pub transcript_cache: TranscriptViewCache,
672 pub transcript_selection: TranscriptSelection,
673 pub selection_autoscroll: Option<SelectionAutoscroll>,
674 pub transcript_scrollbar_dragging: bool,
675 pub last_transcript_area: Option<Rect>,
676 pub last_composer_area: Option<Rect>,
677 /// Painted band occupied by the active inline approval. Stored so wheel
678 /// routing can prefer the visible card over side surfaces underneath it.
679 pub last_approval_area: Option<Rect>,
680 /// WorkflowPanel rect above the composer (#4121), for mouse toggle/cancel.
681 pub last_workflow_panel_area: Option<Rect>,
682 pub last_workflow_cancel_area: Option<Rect>,
683 pub last_transcript_top: usize,
684 pub last_transcript_visible: usize,
685 pub last_transcript_total: usize,
686 pub last_transcript_padding_top: usize,
687 pub jump_to_latest_button_area: Option<Rect>,
688 /// Inner content rect of the composer (excluding border/padding),
689 /// stored at render time for mouse coordinate mapping.
690 pub last_composer_content: Option<Rect>,
691 /// Number of rendered text lines scrolled off the top of the composer,
692 /// stored at render time for mouse coordinate mapping.
693 pub last_composer_scroll_offset: usize,
694 /// Vertical padding above the first text line in the composer,
695 /// stored at render time for mouse coordinate mapping.
696 pub last_composer_top_padding: usize,
697 }
698
699 impl Default for ViewportState {
700 fn default() -> Self {
701 Self {
702 transcript_scroll: TranscriptScroll::to_bottom(),
703 pending_scroll_delta: 0,
704 mouse_scroll: MouseScrollState::new(),
705 transcript_cache: TranscriptViewCache::new(),
706 transcript_selection: TranscriptSelection::default(),
707 selection_autoscroll: None,
708 transcript_scrollbar_dragging: false,
709 last_transcript_area: None,
710 last_composer_area: None,
711 last_approval_area: None,
712 last_workflow_panel_area: None,
713 last_workflow_cancel_area: None,
714 last_transcript_top: 0,
715 last_transcript_visible: 0,
716 last_transcript_total: 0,
717 last_transcript_padding_top: 0,
718 jump_to_latest_button_area: None,
719 last_composer_content: None,
720 last_composer_scroll_offset: 0,
721 last_composer_top_padding: 0,
722 }
723 }
724 }
725
726 /// Verdict for a hunt (#2092).
727 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
728 #[serde(rename_all = "snake_case")]
729 pub enum HuntVerdict {
730 #[default]
731 Hunting,
732 Hunted,
733 Wounded,
734 Escaped,
735 }
736
737 impl HuntVerdict {
738 #[must_use]
739 pub fn goal_status(self) -> crate::tools::goal::GoalStatus {
740 match self {
741 Self::Hunting => crate::tools::goal::GoalStatus::Active,
742 Self::Hunted => crate::tools::goal::GoalStatus::Complete,
743 Self::Wounded => crate::tools::goal::GoalStatus::Paused,
744 Self::Escaped => crate::tools::goal::GoalStatus::Blocked,
745 }
746 }
747
748 #[must_use]
749 pub fn from_goal_status(status: crate::tools::goal::GoalStatus) -> Self {
750 match status {
751 crate::tools::goal::GoalStatus::Active => Self::Hunting,
752 crate::tools::goal::GoalStatus::Paused => Self::Wounded,
753 crate::tools::goal::GoalStatus::Complete => Self::Hunted,
754 crate::tools::goal::GoalStatus::Blocked => Self::Escaped,
755 }
756 }
757 }
758
759 /// Hunt tracking state (#2092 — was GoalState).
760 #[derive(Debug, Clone, Default)]
761 pub struct HuntState {
762 pub quarry: Option<String>,
763 pub token_budget: Option<u32>,
764 pub tokens_used: u64,
765 pub time_used_seconds: u64,
766 pub continuation_count: u32,
767 /// Why an unfinished goal is paused. Kept separate from the four-state
768 /// hunt verdict so usage, budget, and run-limit stops stay distinguishable.
769 pub pause_reason: Option<crate::tools::goal::GoalPauseReason>,
770 pub started_at: Option<Instant>,
771 /// When the goal reached a terminal verdict (Hunted/Wounded/Escaped).
772 /// While `None`, elapsed time keeps growing; once set, the sidebar freezes
773 /// the timer at `finished_at - started_at` so completed goals stop ticking.
774 pub finished_at: Option<Instant>,
775 pub verdict: HuntVerdict,
776 }
777
778 /// Session cost and token telemetry state.
779 #[derive(Debug, Clone)]
780 pub struct SessionState {
781 pub session_cost: f64,
782 pub session_cost_cny: f64,
783 pub subagent_cost: f64,
784 pub subagent_cost_cny: f64,
785 /// Mailbox usage envelopes already accrued, keyed by engine turn and the
786 /// mailbox-local sequence. Sequences restart at one for every turn.
787 pub subagent_cost_event_seqs: HashSet<(String, u64)>,
788 pub displayed_cost_high_water: f64,
789 pub displayed_cost_high_water_cny: f64,
790 pub last_prompt_tokens: Option<u32>,
791 pub last_completion_tokens: Option<u32>,
792 pub last_output_throughput: Option<TokenThroughput>,
793 pub last_prompt_cache_hit_tokens: Option<u32>,
794 pub last_prompt_cache_miss_tokens: Option<u32>,
795 pub last_reasoning_replay_tokens: Option<u32>,
796 pub total_tokens: u32,
797 pub total_conversation_tokens: u32,
798 /// Accumulated token breakdown for the session.
799 pub total_input_tokens: u32,
800 pub total_cache_hit_tokens: u32,
801 pub total_cache_miss_tokens: u32,
802 /// Cache-creation (cache-write) tokens across the session. Tracked as its
803 /// own class because providers that publish a write premium bill it above
804 /// the ordinary input rate, so folding it into misses understated spend.
805 pub total_cache_write_tokens: u32,
806 pub total_output_tokens: u32,
807 /// Turns whose route was money-metered and produced an authoritative
808 /// price. These are exactly the turns inside `session_cost`.
809 pub cost_priced_turns: u32,
810 /// Turns whose route was money-metered — or of unknown billing basis — but
811 /// produced no authoritative price, so they are missing from `session_cost`
812 /// entirely. `/cost` reports this instead of presenting the subtotal as a
813 /// complete figure.
814 pub cost_unpriced_turns: u32,
815 /// CNY-specific coverage. Most providers publish USD only, so these cannot
816 /// share the USD counters without falsely calling a mixed-route CNY subtotal
817 /// complete.
818 pub cost_cny_priced_turns: u32,
819 pub cost_cny_unpriced_turns: u32,
820 /// Stable reason labels for the unpriced turns, in sorted order.
821 ///
822 /// `String` rather than `&'static str` because this state round-trips
823 /// through a saved session: a label read back from disk was written by some
824 /// build's vocabulary, not necessarily this one's.
825 pub cost_unpriced_reasons: BTreeSet<String>,
826 pub cost_cny_unpriced_reasons: BTreeSet<String>,
827 /// Token classes used on some route this session that carry no published
828 /// price. Their turns fail closed rather than under-report.
829 pub cost_unpriced_classes: BTreeSet<String>,
830 /// Provenance labels of the pricing rows behind the priced turns
831 /// (`models_dev_bundled`, `provider_live`, `provider_docs`, …).
832 pub cost_pricing_provenances: BTreeSet<String>,
833 /// Live-pricing downgrade receipts: a live catalog row that could not be
834 /// verified for the endpoint that served a turn, so the bundled snapshot was
835 /// used instead of claiming authoritative live provenance.
836 pub cost_live_pricing_defects: BTreeSet<String>,
837 /// Live-pricing defects for which no bundled row could produce a price.
838 pub cost_live_pricing_unusable_defects: BTreeSet<String>,
839 /// One redacted receipt per distinct audited route:
840 /// provider, configured identity, wire model, billing surface, endpoint
841 /// fingerprint, billing mode, currency. Never a URL, credential, or filesystem path.
842 pub cost_route_receipts: BTreeSet<String>,
843 /// True when the restored session has no coverage state at all.
844 ///
845 /// Sessions written before coverage was tracked deserialize their new fields
846 /// from serde defaults, which look exactly like "0 priced, 0 unpriced" — i.e.
847 /// a complete total covering nothing. That reading is false, so the load path
848 /// marks the session explicitly unknown and `/cost` says so rather than
849 /// presenting fabricated completeness, even for an all-zero record (#4318).
850 pub cost_coverage_unknown_legacy: bool,
851 pub turn_cache_history: VecDeque<TurnCacheRecord>,
852 pub last_cache_inspection: Option<PromptInspection>,
853 pub last_warmup_key: Option<CacheWarmupKey>,
854 /// Tool catalog from the most recent model request.
855 ///
856 /// `/cache inspect` uses this to inspect the same tool schema bytes
857 /// that were eligible for the provider's prefix cache.
858 pub last_tool_catalog: Option<Vec<Tool>>,
859 /// Exact tool field captured at the latest model request seam.
860 pub last_tool_request_snapshot: Option<crate::tool_inspection::ToolInspectionSnapshot>,
861 /// API base URL used by the most recent model request or cache warmup.
862 pub last_base_url: Option<String>,
863 }
864
865 /// Sidebar hover state for mouse tooltip support.
866 #[derive(Debug, Clone, Default)]
867 pub struct SidebarHoverState {
868 /// Rendered sections with their areas and full-text lines.
869 pub sections: Vec<SidebarHoverSection>,
870 }
871
872 /// Per-row metadata for sidebar detail popovers.
873 #[derive(Debug, Clone, PartialEq, Eq)]
874 pub enum SidebarRowAction {
875 Command(String),
876 /// Put a destructive command in the composer instead of executing it.
877 /// The user confirms with Enter or cancels by editing/clearing the draft.
878 #[allow(dead_code)] // destructive confirm path; mouse_ui already matches it (TUI-DOG-008)
879 PrefillCommand(String),
880 ToggleAgentDetails {
881 agent_id: String,
882 },
883 /// Select the persistent Agents panel. This is deliberately a navigation
884 /// action rather than a modal: the Subagents summary is a group door, so
885 /// it should reveal the standing register instead of fabricating a detail
886 /// page for the count itself.
887 ShowSubagentsPanel,
888 /// Open the child's bounded, safe status projection. Exact transcript
889 /// evidence is a separate explicit action (#2889).
890 OpenAgentDetail {
891 agent_id: String,
892 },
893 /// Open the child's artifact-first exact transcript. This is separate
894 /// from the safe default details projection (#2889).
895 OpenAgentTranscript {
896 agent_id: String,
897 },
898 CancelAgent {
899 agent_id: String,
900 },
901 /// Open the Work Graph inspector in the shared pager. Any lifecycle stop
902 /// action is carried into that inspector instead of consuming row width.
903 InspectWork {
904 title: String,
905 body: String,
906 stop_action: Option<Box<SidebarRowAction>>,
907 },
908 }
909
910 impl SidebarRowAction {
911 #[must_use]
912 pub fn as_command(&self) -> Option<&str> {
913 match self {
914 Self::Command(command) => Some(command.as_str()),
915 Self::PrefillCommand(_)
916 | Self::ToggleAgentDetails { .. }
917 | Self::ShowSubagentsPanel
918 | Self::OpenAgentDetail { .. }
919 | Self::OpenAgentTranscript { .. }
920 | Self::CancelAgent { .. }
921 | Self::InspectWork { .. } => None,
922 }
923 }
924 }
925
926 /// Per-row metadata for sidebar detail popovers.
927 #[derive(Debug, Clone, PartialEq, Eq)]
928 pub struct SidebarHoverRow {
929 /// Absolute row position in the terminal.
930 pub row_y: u16,
931 /// Text shown in the compact sidebar row.
932 pub display_text: String,
933 /// Full untruncated text for the popover.
934 pub full_text: String,
935 /// Optional additional detail line.
936 pub detail: Option<String>,
937 /// Whether the compact row lost information.
938 pub is_truncated: bool,
939 /// Slash command to execute when this row is clicked (#3028).
940 /// `shell_*` job ids route through `/jobs` (e.g. `/jobs cancel
941 /// shell_abc123`); task-manager ids route through `/task` (e.g.
942 /// `/task show task_abc123`).
943 pub click_action: Option<SidebarRowAction>,
944 /// Optional narrower stop target for rows that show an inline `[x]`.
945 pub stop_action: Option<SidebarRowAction>,
946 pub stop_zone_start_col: Option<u16>,
947 pub stop_zone_end_col: Option<u16>,
948 }
949
950 /// Per-section metadata for sidebar hover detection.
951 #[derive(Debug, Clone)]
952 pub struct SidebarHoverSection {
953 /// Content area within the section (inside border + padding).
954 pub content_area: Rect,
955 /// Full original text for each content line rendered.
956 pub lines: Vec<String>,
957 /// Per-row metadata for rich hover popovers.
958 pub rows: Vec<SidebarHoverRow>,
959 }
960
961 impl Default for SessionState {
962 fn default() -> Self {
963 Self {
964 session_cost: 0.0,
965 session_cost_cny: 0.0,
966 subagent_cost: 0.0,
967 subagent_cost_cny: 0.0,
968 subagent_cost_event_seqs: HashSet::new(),
969 displayed_cost_high_water: 0.0,
970 displayed_cost_high_water_cny: 0.0,
971 last_prompt_tokens: None,
972 last_completion_tokens: None,
973 last_output_throughput: None,
974 last_prompt_cache_hit_tokens: None,
975 last_prompt_cache_miss_tokens: None,
976 last_reasoning_replay_tokens: None,
977 total_tokens: 0,
978 total_conversation_tokens: 0,
979 total_input_tokens: 0,
980 total_cache_hit_tokens: 0,
981 total_cache_miss_tokens: 0,
982 total_cache_write_tokens: 0,
983 total_output_tokens: 0,
984 cost_priced_turns: 0,
985 cost_unpriced_turns: 0,
986 cost_cny_priced_turns: 0,
987 cost_cny_unpriced_turns: 0,
988 cost_unpriced_reasons: BTreeSet::new(),
989 cost_cny_unpriced_reasons: BTreeSet::new(),
990 cost_unpriced_classes: BTreeSet::new(),
991 cost_pricing_provenances: BTreeSet::new(),
992 cost_live_pricing_defects: BTreeSet::new(),
993 cost_live_pricing_unusable_defects: BTreeSet::new(),
994 cost_route_receipts: BTreeSet::new(),
995 cost_coverage_unknown_legacy: false,
996 turn_cache_history: VecDeque::new(),
997 last_cache_inspection: None,
998 last_warmup_key: None,
999 last_tool_catalog: None,
1000 last_tool_request_snapshot: None,
1001 last_base_url: None,
1002 }
1003 }
1004 }
1005
1006 impl SessionState {
1007 /// Reset the accumulated token breakdown fields to zero.
1008 pub fn reset_token_breakdown(&mut self) {
1009 self.total_input_tokens = 0;
1010 self.total_cache_hit_tokens = 0;
1011 self.total_cache_miss_tokens = 0;
1012 self.total_cache_write_tokens = 0;
1013 self.total_output_tokens = 0;
1014 self.last_output_throughput = None;
1015 }
1016 }
1017
1018 /// Evidence collected during a turn for the post-turn receipt.
1019 #[derive(Debug, Clone)]
1020 pub struct ToolEvidence {
1021 pub tool_name: String,
1022 pub summary: String,
1023 }
1024
1025 #[derive(Debug, Clone)]
1026 pub(crate) struct PendingProviderSwitch {
1027 pub previous_provider: ApiProvider,
1028 pub previous_model: String,
1029 pub previous_model_ids_passthrough: bool,
1030 pub previous_route_limits: Option<RouteLimits>,
1031 pub previous_route_base_url: String,
1032 pub previous_context_window_source: crate::route_runtime::ContextWindowSource,
1033 pub previous_context_window_override: Option<u32>,
1034 pub previous_config: Config,
1035 pub previous_onboarding: OnboardingState,
1036 pub previous_onboarding_needs_api_key: bool,
1037 pub previous_api_key_env_only: bool,
1038 }
1039
1040 /// Opaque completion returned by a spawned dispatch task. It carries the
1041 /// captured data needed to apply success or rollback on the event loop.
1042 pub type DispatchApplyFn = Box<
1043 dyn FnOnce(
1044 &mut App,
1045 &crate::core::engine::EngineHandle,
1046 &crate::config::Config,
1047 ) -> anyhow::Result<()>
1048 + Send,
1049 >;
1050
1051 /// Global UI state for the TUI.
1052 #[allow(clippy::struct_excessive_bools)]
1053 /// A route change made in-session that the user has not yet decided how to
1054 /// save. Route changes are temporary by default; persisting them requires an
1055 /// explicit choice (Update this Fleet / Save as a new Fleet / Remember as my
1056 /// default / Keep for this session only).
1057 #[derive(Debug, Clone, PartialEq, Eq)]
1058 pub struct PendingRouteSave {
1059 /// Provider identity the session is now on.
1060 pub provider_identity: String,
1061 /// Exact model id the session is now on.
1062 pub model: String,
1063 /// The selected Fleet at change time, when one exists.
1064 pub fleet: Option<(String, crate::fleet::store::FleetScope)>,
1065 }
1066
1067 /// Write `provider_identity`/`model` to `settings.toml` as the route the next
1068 /// launch should open with, and return the line to show the operator.
1069 ///
1070 /// `default_provider` is what `App::new` consults first, so pinning it is the
1071 /// half that actually survives a restart; the provider-scoped entry carries the
1072 /// model. `default_model` is a DeepSeek-only legacy key and is written only for
1073 /// those providers, matching how startup reads it back.
1074 fn persist_route_as_startup_default(provider_identity: &str, model: &str) -> String {
1075 let route = format!("{provider_identity}/{model}");
1076 match crate::settings::Settings::transact(|settings| {
1077 settings.default_provider = Some(provider_identity.to_string());
1078 settings.set_model_for_provider(provider_identity, model);
1079 if matches!(
1080 crate::config::ApiProvider::parse(provider_identity),
1081 Some(crate::config::ApiProvider::Deepseek)
1082 | Some(crate::config::ApiProvider::DeepseekCN)
1083 ) {
1084 settings.set("default_model", model)?;
1085 }
1086 Ok(())
1087 }) {
1088 Ok(()) => format!("Remembered {route} as the startup default (settings.toml)."),
1089 Err(err) => format!("Save failed: {err}"),
1090 }
1091 }
1092
1093 pub struct App {
1094 pub mode: AppMode,
1095 /// Registered hotbar actions available for future slot config/render layers.
1096 #[allow(dead_code)]
1097 pub hotbar_actions: HotbarActionRegistry,
1098 /// Composer sub-state (input, cursor, history, menus).
1099 pub composer: ComposerState,
1100 /// Viewport sub-state (scroll, cache, selection).
1101 pub viewport: ViewportState,
1102 /// Ocean work-surface state. Kept separate from transcript/sidebar state
1103 /// so the replacement shell can be removed or promoted as one unit.
1104 pub work_surface: crate::tui::work_surface::WorkSurfaceState,
1105 /// Goal sub-state.
1106 pub hunt: HuntState,
1107 /// Session sub-state (cost, tokens, telemetry).
1108 pub session: SessionState,
1109 /// Active tool restriction from custom slash command frontmatter.
1110 /// `None` means the current turn may use the normal tool set.
1111 pub active_allowed_tools: Option<Vec<String>>,
1112 /// True when the active custom slash command opted into pause/resume.
1113 pub pausable: bool,
1114 /// A route change made in-session awaits an explicit save decision. When
1115 /// set, the next key press opens the route-save prompt unless a modal is
1116 /// already open.
1117 pub pending_route_save: Option<PendingRouteSave>,
1118 /// True after Esc paused a pausable command and before it is resumed or cancelled.
1119 pub paused: bool,
1120 /// Saved custom-command objective while the command is paused.
1121 pub paused_quarry: Option<String>,
1122 pub history: Vec<HistoryCell>,
1123 pub history_version: u64,
1124 /// Per-cell revision counter, kept in lockstep with `history`.
1125 pub history_revisions: Vec<u64>,
1126 /// Cached tool-run grouping for transcript collapse. The detector is
1127 /// keyed by the same mutation generation that invalidates transcript
1128 /// cells, so idle frames do not rescan the full history.
1129 pub(crate) tool_run_cache: ToolRunCache,
1130 /// Monotonic counter used to issue fresh per-cell revisions.
1131 pub next_history_revision: u64,
1132 pub api_messages: Vec<Message>,
1133 pub(crate) context_token_cache: RefCell<ContextTokenCache>,
1134 /// Typed account-owned browser relay for this exact TUI session.
1135 pub remote_control: crate::remote_control::RemoteControlController,
1136 pub start_remote_control_on_launch: bool,
1137 pub is_loading: bool,
1138 /// Sender for spawned dispatch tasks to report completion back to the
1139 /// event loop. The closure is called with `&mut App` so the async phase
1140 /// never needs `&mut App` while awaiting network I/O (#4605).
1141 pub dispatch_completion_tx: Option<tokio::sync::mpsc::Sender<DispatchApplyFn>>,
1142 /// True while a spawned dispatch task is in flight (#4605). Set in the
1143 /// sync prepare phase and cleared when the completion closure runs, so a
1144 /// submit after an Esc-cancel (which clears `is_loading`) still queues
1145 /// instead of spawning a second dispatch that could reorder ops.
1146 pub dispatch_in_flight: bool,
1147 /// Timestamp of the most recent Enter while the engine was busy.
1148 /// Retained for session layout compatibility; bare-Enter double-tap
1149 /// steering was removed (use Ctrl+Enter instead).
1150 #[allow(dead_code)]
1151 pub last_enter_instant: Option<Instant>,
1152 /// Whether the once-per-turn provider-wait incident (#3095) has already
1153 /// been logged for the current turn.
1154 pub provider_wait_incident_logged: bool,
1155 /// Ghost-text follow-up suggestion shown in the composer when empty.
1156 /// Generated asynchronously after each completed turn; cleared on new input.
1157 pub prompt_suggestion: Option<String>,
1158 /// Monotonic turn counter for stale-suggestion protection. Incremented on
1159 /// each TurnStarted; background suggestion tasks capture the token and
1160 /// discard their result if the token no longer matches.
1161 pub prompt_suggestion_gen: std::sync::atomic::AtomicU64,
1162 /// Degraded connectivity mode; new user inputs are queued for later retry.
1163 pub offline_mode: bool,
1164 /// Whether an `EngineEvent::Error` has already been posted for the
1165 /// current turn. Suppresses the redundant "Turn failed:" status line
1166 /// that `TurnComplete { error: .. }` would otherwise emit on top of
1167 /// the in-transcript error cell.
1168 pub turn_error_posted: bool,
1169 /// Legacy status text sink retained for compatibility with existing call sites.
1170 pub status_message: Option<String>,
1171 /// Recent status toasts (ephemeral, newest at back).
1172 pub status_toasts: VecDeque<StatusToast>,
1173 /// Header chip label (e.g. `↑ v0.9.5`) set once by the fire-and-forget
1174 /// startup version check when a newer stable release exists. Drives the
1175 /// small persistent update chip in the header so the affordance survives
1176 /// the transient toast without nagging (FINISH-0.9.4 #14).
1177 pub update_available: Option<String>,
1178 /// Sticky status toast used for important warnings/errors.
1179 pub sticky_status: Option<StatusToast>,
1180 /// Last status text already promoted from `status_message` into toast state.
1181 pub last_status_message_seen: Option<String>,
1182 pub model: String,
1183 /// Persisted model selections by provider name. Loaded from settings so
1184 /// `/model` and the picker can surface saved provider-specific choices.
1185 pub provider_models: HashMap<String, String>,
1186 /// Additive provider-scoped model IDs enabled for the ordinary picker.
1187 /// The catalog remains separately discoverable and selecting from it adds
1188 /// to this set rather than replacing earlier enabled choices.
1189 pub enabled_provider_models: HashMap<String, Vec<String>>,
1190 /// Exact provider/model pins loaded from settings, in user order.
1191 pub pinned_models: Vec<crate::settings::PinnedModel>,
1192 /// When true, the model is auto-selected based on request complexity
1193 /// rather than using a fixed model. The `/model auto` command sets this.
1194 /// `dispatch_user_message` calls `auto_model_heuristic` to resolve the
1195 /// effective model for each outbound message.
1196 pub auto_model: bool,
1197 /// Last concrete model chosen while `auto_model` is active.
1198 pub last_effective_model: Option<String>,
1199 /// Provider that actually served the latest auto-routed turn.
1200 pub last_effective_provider: Option<ApiProvider>,
1201 /// Exact non-secret identity for the provider that served the latest Auto
1202 /// turn. This matters for named custom providers, which all share the
1203 /// `ApiProvider::Custom` enum variant.
1204 pub(crate) last_effective_provider_identity: Option<String>,
1205 /// Auto decision metadata for the most recently resolved Auto turn.
1206 pub(crate) last_auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
1207 /// Route selected for the next turn, retained for in-flight UI details
1208 /// until the engine confirms the authoritative `TurnStarted` route.
1209 pub pending_turn_route: Option<(ApiProvider, String, bool)>,
1210 /// Auto decision metadata waiting to be paired with `pending_turn_route`.
1211 pub(crate) pending_auto_route_receipt: Option<crate::model_routing::AutoRouteReceipt>,
1212 /// Authoritative lifecycle metadata attached to the most recent
1213 /// `TurnStarted`. Kept separate from `pending_turn_route` so a preceding
1214 /// compaction completion cannot consume the next model turn's route.
1215 pub active_turn: Option<ActiveTurnMetadata>,
1216 /// Current API provider (mirrors `Config::api_provider`).
1217 /// Updated by `/provider` switches so the UI/commands can read the
1218 /// active backend without re-deriving it from the live config.
1219 pub api_provider: ApiProvider,
1220 /// Exact configured provider key for persistence and route restoration.
1221 /// Built-ins use their canonical slug; named custom providers retain the
1222 /// user-owned key instead of collapsing to `custom`.
1223 pub(crate) provider_identity: String,
1224 /// Additive exact configured id for persistence. `None` preserves the
1225 /// legacy root-level custom route even when a same-key table appears.
1226 pub(crate) provider_exact_id: Option<String>,
1227 /// Primary provider plus configured fallback providers for this session.
1228 pub provider_chain: Option<ProviderChain>,
1229 /// Per-provider auth/local readiness snapshot for the fallback chain (#2574).
1230 ///
1231 /// Captured at startup alongside `provider_chain` (where the live `Config` is
1232 /// in scope). `advance_fallback` consults it to skip chain entries that
1233 /// cannot serve a turn — hosted providers missing a key — while local
1234 /// providers (Ollama/vLLM/SGLang) are always ready. Stored as `(provider,
1235 /// ready)` pairs; lookups fall back to "ready" for providers not present so
1236 /// an unknown entry is tried rather than silently skipped.
1237 provider_readiness: Vec<(ApiProvider, bool)>,
1238 /// Session-local evidence from real provider requests and verification
1239 /// probes. Unlike `provider_readiness` above, this never treats a saved key
1240 /// as proof that the endpoint is healthy.
1241 pub(crate) provider_health: crate::provider_readiness::ProviderReadinessSnapshot,
1242 /// Human-readable description of the last provider fallback event.
1243 pub last_fallback_reason: Option<String>,
1244 /// True when the active provider/base URL accepts arbitrary model IDs
1245 /// verbatim rather than DeepSeek-only aliases.
1246 pub model_ids_passthrough: bool,
1247 /// Resolved provider/model route limits for the active runtime route.
1248 pub active_route_limits: Option<RouteLimits>,
1249 /// Exact resolved endpoint for the active runtime route. This stays
1250 /// separate from persisted config so endpoint-sensitive compatibility
1251 /// (notably Kimi Code's bare `k3`) is never inferred from a provider name
1252 /// alone.
1253 pub active_route_base_url: String,
1254 /// Provenance for `active_route_limits`' effective context window. This
1255 /// is an operator-facing receipt, not a claim about provider billing.
1256 pub active_context_window_source: crate::route_runtime::ContextWindowSource,
1257 /// User-configured provider context-window override for the active route.
1258 pub active_context_window_override: Option<u32>,
1259 /// Pending provider transition for transactional rollback when the next
1260 /// auth failure indicates the new provider cannot be used.
1261 pub pending_provider_switch: Option<PendingProviderSwitch>,
1262 /// Current live reasoning-effort selection. Route changes may normalize
1263 /// this value; the raw user choice remains in
1264 /// [`Self::reasoning_effort_preference`].
1265 pub reasoning_effort: ReasoningEffort,
1266 /// Raw explicit user preference, before any fixed provider/model route
1267 /// normalizes it. `None` means the current live tier is an implicit route
1268 /// default or compatibility inference and must not constrain Auto routing.
1269 pub(crate) reasoning_effort_preference: Option<ReasoningEffort>,
1270 /// Last effective thinking receipt for the most recently accepted route.
1271 pub(crate) last_effective_reasoning_effort: Option<EffectiveReasoningEffort>,
1272 pub workspace: PathBuf,
1273 /// Effective explicit/managed filesystem scope captured at startup. The
1274 /// named permission posture supplies the default when this is `None`.
1275 pub configured_sandbox_mode: Option<String>,
1276 /// The sandbox backend this platform+config can actually enforce with,
1277 /// resolved once at startup. `None` means there is NO enforcement
1278 /// available (default Linux without `prefer_bwrap`, and all Windows), so
1279 /// surfaces must not claim the session is sandboxed (2026-08-04 audit).
1280 pub sandbox_backend: Option<crate::sandbox::SandboxType>,
1281 /// Off-event-loop worker for durable Lane control writes. `/lane interrupt`
1282 /// submits here instead of tearing down a Runtime on the composer thread
1283 /// (#4022).
1284 pub lane_control: crate::lane_control::LaneControlQueue,
1285 /// Immutable plugin catalogue scoped to this App's effective workspace.
1286 pub plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
1287 pub config_path: Option<PathBuf>,
1288 pub config_profile: Option<String>,
1289 /// Legacy executable plugin-tool directory resolved from the already
1290 /// loaded configuration. Slash-command inventory must not reload the full
1291 /// config (and thereby re-read credential-bearing fields) merely to find
1292 /// this path.
1293 pub legacy_plugin_tools_dir: Option<PathBuf>,
1294 pub mcp_config_path: PathBuf,
1295 pub skills_dir: PathBuf,
1296 pub skills_scan_codewhale_only: bool,
1297 /// Whether the optional project context pack was enabled when this
1298 /// session loaded its configuration. Context diagnostics consult this
1299 /// source of truth even before the first system prompt is assembled.
1300 pub project_context_pack_enabled: bool,
1301 /// Path to the user-memory file (#489). Always populated; only
1302 /// consulted when `use_memory` is `true`.
1303 pub memory_path: PathBuf,
1304 /// Whether the user-memory feature is enabled (#489). Mirrors
1305 /// `Config::memory_enabled()` at app boot. Used by the `# foo`
1306 /// composer interception,
1307 /// the `/memory` slash command, and tool registration for
1308 /// `remember`.
1309 pub use_memory: bool,
1310 pub use_alt_screen: bool,
1311 pub use_mouse_capture: bool,
1312 /// When true, plain Up/Down on an empty composer scroll the transcript
1313 /// instead of navigating input history. Defaults to `true` when mouse
1314 /// capture is off: terminals that convert mouse-wheel events to arrow-key
1315 /// sequences (e.g. Windows CMD without `WT_SESSION`) get page-scrolling
1316 /// without any explicit config (#1443).
1317 pub composer_arrows_scroll: bool,
1318 /// Data-side cap for the `@`-mention popup. The renderer still limits the
1319 /// visible rows to available terminal height.
1320 pub mention_menu_limit: usize,
1321 /// Maximum workspace depth for `@`-mention completion walks. `0` means
1322 /// unlimited depth.
1323 pub mention_walk_depth: usize,
1324 /// `@`-mention completion behavior: fuzzy workspace search or deterministic
1325 /// directory browser.
1326 pub mention_menu_behavior: String,
1327 /// Follow symbolic links during workspace file discovery walks.
1328 /// When `true`, symlinked directories are traversed, enabling
1329 /// multi-project workspaces.
1330 pub workspace_follow_symlinks: bool,
1331 pub use_bracketed_paste: bool,
1332 pub use_paste_burst_detection: bool,
1333 /// Set to `true` the first time a real `Event::Paste` arrives during a
1334 /// session. Once set, `handle_paste_burst_key` short-circuits — there's
1335 /// no point running the rapid-keypress heuristic on a terminal that
1336 /// already delivers paste-as-event correctly. Avoids paste-burst false
1337 /// positives on Ghostty / iTerm2 / WezTerm / Windows Terminal where
1338 /// fast typing or IME commits could otherwise be mis-classified as a
1339 /// paste burst (#1322 follow-up).
1340 pub bracketed_paste_seen: bool,
1341 #[allow(dead_code)]
1342 pub system_prompt: Option<SystemPrompt>,
1343 pub auto_compact: bool,
1344 pub auto_compact_user_configured: bool,
1345 pub auto_compact_threshold_percent: f64,
1346 pub stopped_turn: bool,
1347 pub calm_mode: bool,
1348 pub low_motion: bool,
1349 pub constrained_frame_rate: bool,
1350 pub ocean_started_at: Instant,
1351 /// The ambient animation clock, in clamped milliseconds. Creature and
1352 /// water positions are pure functions of this value; advancing it by at
1353 /// most [`App::AMBIENT_MAX_STEP_MS`] per sampled frame keeps motion
1354 /// continuous when draws arrive in bursts (fast token streams previously
1355 /// sampled raw wall-clock time at irregular gaps, so fish "teleported"
1356 /// between frames — captains-log #16).
1357 pub ambient_clock_ms: u128,
1358 /// When the ambient clock last advanced; `None` until the first sample.
1359 pub ambient_clock_sampled_at: Option<Instant>,
1360 /// When the shell last became fully idle (no turn, no live sub-agents,
1361 /// no active durable tasks, completion exhale finished). After a short
1362 /// grace of gentle motion the aquarium settles to a genuinely still
1363 /// scene instead of repainting an idle screen forever.
1364 pub ambient_idle_since: Option<Instant>,
1365 /// Start of the underwater shell's one-shot successful-turn exhale.
1366 /// Kept separate from the ambient ocean clock so completion can settle
1367 /// once without restarting or repainting the transcript field.
1368 pub ocean_completion_started_at: Option<Instant>,
1369 /// History length at the current turn boundary. Successful completion
1370 /// uses this stable index to settle only the receipts produced by that
1371 /// turn, never old transcript rows.
1372 pub ocean_turn_history_start: usize,
1373 /// First committed history cell participating in the current one-shot
1374 /// receipt-settle cascade.
1375 pub ocean_receipt_settle_start: Option<usize>,
1376 /// Enables the authored underwater phase and ambient motion system.
1377 pub fancy_animations: bool,
1378 /// Typed appearance treatment; appearance is independent from motion
1379 /// settings, and every underwater treatment keeps ambient life.
1380 pub ocean_treatment: crate::tui::ocean::OceanTreatment,
1381 /// Focus-context texture prototype mode (#4823), parsed once from the
1382 /// `focus_texture` setting. `Off` by default; while off the modal render
1383 /// path is byte-identical to the pre-prototype path.
1384 pub focus_texture: crate::tui::focus_texture::FocusTextureMode,
1385 /// Distinct pre-session menu. Once dismissed, the normal idle ocean owns
1386 /// the empty session and this state stays hidden.
1387 pub launch: LaunchState,
1388 /// Mouse-selected launch action, consumed by the async UI loop.
1389 pub pending_launch_action: Option<crate::tui::underwater::LaunchAction>,
1390 /// Mouse-selected hotbar slot, consumed by the async UI loop.
1391 pub pending_hotbar_slot: Option<u8>,
1392 /// Whether the renderer should wrap each frame in DEC mode 2026
1393 /// synchronized output. Resolved from `Settings::synchronized_output`
1394 /// at construction; `auto`/`on` → `true`, `off` → `false`. The Ptyxis
1395 /// auto-detect path in `Settings::apply_env_overrides` flips `auto`
1396 /// to `off` before App is built, so by the time we read this flag in
1397 /// the draw loop the decision is already made. See the
1398 /// `Settings::synchronized_output` doc for the user-facing knob.
1399 pub synchronized_output_enabled: bool,
1400 /// Header status-indicator chip mode. `"cw"` is the static default;
1401 /// `"whale"` and `"dots"` preserve the animated legacy choices, while
1402 /// `"off"` hides the chip. Loaded from settings and changed via
1403 /// `/config status_indicator <cw|whale|dots|off>`.
1404 pub status_indicator: String,
1405 pub show_thinking: bool,
1406 pub thinking_highlight: bool,
1407 pub thinking_default_expanded: bool,
1408 pub verbose_transcript: bool,
1409 pub show_tool_details: bool,
1410 /// Inline presentation mode for successful structured File mutations.
1411 /// Exact evidence remains attached to each mutation receipt in all modes.
1412 pub inline_diff_mode: InlineDiffMode,
1413 pub ui_locale: Locale,
1414 pub cost_currency: CostCurrency,
1415 /// Route payment truth. Model pricing alone cannot distinguish metered
1416 /// API calls from OAuth or token-plan quota.
1417 pub billing_presentation: crate::route_billing::BillingPresentation,
1418 pub composer_density: ComposerDensity,
1419 pub composer_border: bool,
1420 /// Voice input state — toggled by `/voice` and the voice hotbar action.
1421 pub voice_enabled: bool,
1422 /// Auto-send after transcription when the transcript ends with an
1423 /// explicit send instruction ("send it" / "发送"). Toggled by `/voice-send`.
1424 pub voice_send_enabled: bool,
1425 /// AI-assisted dictation that sees the current composer text.
1426 /// Toggled by `/voice-control`.
1427 pub voice_control_enabled: bool,
1428 pub transcript_spacing: TranscriptSpacing,
1429 /// Sidebar hover state for mouse tooltip support.
1430 pub sidebar_hover: SidebarHoverState,
1431 /// Current hover tooltip text, if any.
1432 pub sidebar_hover_tooltip: Option<String>,
1433 /// Last successfully rendered Work panel summary. Transient mutex misses
1434 /// should not wipe settled To-do state from the sidebar.
1435 pub(crate) cached_work_summary: Option<SidebarWorkSummary>,
1436 /// Browsing context from the last dismissed `/model` picker, so reopening
1437 /// restores the view mode and highlighted row instead of resetting to the
1438 /// top (#4109 picker memory). Session-scoped, never persisted.
1439 pub model_picker_memory: Option<ModelPickerMemory>,
1440 /// Browsing context from the last dismissed `/provider` picker.
1441 pub provider_picker_memory: Option<ProviderPickerMemory>,
1442 /// Last known mouse position for tooltip placement.
1443 pub last_mouse_pos: Option<(u16, u16)>,
1444 /// Whether the session-context panel is enabled (#504).
1445 pub context_panel: bool,
1446 /// Whether the persistent Sessions rail is enabled (#2934). Opt-in.
1447 pub sessions_rail: bool,
1448 /// Minimum number of consecutive safe tool cells needed for auto-collapse.
1449 ///
1450 /// Fixed at 3 for v0.9.x (#3256 decision): not a user setting. Rollups need
1451 /// enough cells to be readable; exposing a knob without UX for partial
1452 /// runs would just recreate the pre-collapse noise floor.
1453 pub tool_collapse_threshold: usize,
1454 /// Tool runs the user explicitly expanded. Stores original history indices.
1455 pub expanded_tool_runs: HashSet<usize>,
1456 /// Current dense tool-run collapse behavior.
1457 pub tool_collapse_mode: ToolCollapseMode,
1458 /// File-tree pane state. `None` when hidden; `Some` when visible.
1459 pub file_tree: Option<crate::tui::file_tree::FileTreeState>,
1460 /// Whether the file-tree pane was actually rendered in the last frame.
1461 /// Set false when the terminal is too narrow to show the tree.
1462 pub file_tree_visible: bool,
1463 #[allow(dead_code)]
1464 pub compact_threshold: usize,
1465 pub max_input_history: usize,
1466 pub allow_shell: bool,
1467 pub verbosity: Option<String>,
1468 pub max_subagents: usize,
1469 /// Per-SSE-chunk idle timeout for streamed turns, in seconds.
1470 pub stream_chunk_timeout_secs: u64,
1471 /// Cached sub-agent snapshots for UI views.
1472 pub subagent_cache: Vec<SubAgentResult>,
1473 /// First time this TUI observed each terminal sub-agent card.
1474 pub subagent_terminal_seen_at: HashMap<String, Instant>,
1475 /// Last known per-agent progress text for running sub-agents.
1476 pub agent_progress: HashMap<String, String>,
1477 /// Agent rows expanded by direct sidebar interaction.
1478 pub expanded_sidebar_agents: HashSet<String>,
1479 /// Parent/depth metadata for live progress-only sub-agent rows.
1480 pub agent_progress_meta: HashMap<String, AgentProgressMeta>,
1481 /// In-transcript sub-agent card index by `agent_id` (issue #128).
1482 /// Maps each live sub-agent to the `HistoryCell::SubAgent` it renders
1483 /// into, so successive mailbox envelopes mutate the same cell rather
1484 /// than spawning duplicates.
1485 pub subagent_card_index: HashMap<String, usize>,
1486 /// History index of the most recent FanoutCard. Sibling sub-agents
1487 /// spawned by the same `rlm` invocation route into this card; reset
1488 /// when a fresh fanout-family tool call starts.
1489 pub last_fanout_card_index: Option<usize>,
1490 /// Most recently observed sub-agent dispatch tool name (set on
1491 /// `ToolCallStarted` for `agent` / `rlm` / etc., cleared
1492 /// after the first `Started` mailbox envelope routes through it).
1493 pub pending_subagent_dispatch: Option<String>,
1494 /// Animation anchor for status-strip active sub-agent spinner.
1495 pub agent_activity_started_at: Option<Instant>,
1496 /// Monotonic counter for stable agent labels (#3030).
1497 /// Incremented each time a sub-agent is spawned; used to generate
1498 /// "Agent 1", "Agent 2", etc.
1499 pub agent_counter: u64,
1500 /// Maps raw agent_id to a stable user-facing label (#3030).
1501 /// Populated when `AgentSpawned` fires; read by sidebar rendering.
1502 pub agent_label_map: HashMap<String, String>,
1503 /// Last time a sub-agent progress event triggered a redraw.
1504 /// Used to throttle redraws under high sub-agent concurrency (#3033).
1505 pub last_agent_progress_redraw: Option<Instant>,
1506 /// Last time a workflow `budget_updated` event was allowed to request a
1507 /// repaint. High-signal workflow events (task/run lifecycle) always paint;
1508 /// budget-only chatter is paced under fan-out (#4095 residual).
1509 pub last_workflow_budget_redraw: Option<Instant>,
1510 pub ui_theme: UiTheme,
1511 /// Parsed `background_color` setting, kept separately from `ui_theme` so
1512 /// an explicit override remains distinguishable even when it happens to
1513 /// equal the current named theme's default surface and can still carry
1514 /// into previews of other themes.
1515 pub background_color_override: Option<Color>,
1516 /// Active named theme. Drives the cell-level color remap in
1517 /// `tui::color_compat::ColorCompatBackend` so community presets
1518 /// (Catppuccin, Tokyo Night, Dracula, Gruvbox) propagate to every
1519 /// render site, not just the handful that read `app.ui_theme`.
1520 pub theme_id: palette::ThemeId,
1521 // Onboarding
1522 pub onboarding: OnboardingState,
1523 pub onboarding_needs_api_key: bool,
1524 pub onboarding_provider: ApiProvider,
1525 pub onboarding_workspace_trust_gate: bool,
1526 /// True when onboarding opened only because a returning user's configured
1527 /// provider is missing its key. Esc then exits to the offline composer
1528 /// instead of walking back through first-run steps.
1529 pub onboarding_missing_key_recovery: bool,
1530 /// True when the user explicitly chose "Explore offline" during onboarding
1531 /// (#3927). No provider was selected, no route was activated, and no secret
1532 /// was saved: the session browses with queued input until a route is
1533 /// activated later (`/provider`), which is the only thing that clears it.
1534 pub onboarding_explore_offline: bool,
1535 /// First-run route receipts used by the mental-model screen's Back action.
1536 pub onboarding_had_provider_step: bool,
1537 pub onboarding_had_trust_step: bool,
1538 /// True when the active credential was discovered only through an
1539 /// environment variable. Missing-key recovery and route rollback use this
1540 /// provenance to decide whether a durable provider slot still exists;
1541 /// credential drafts live exclusively inside `ProviderPickerView`.
1542 pub api_key_env_only: bool,
1543 // Hooks system
1544 pub hooks: HookExecutor,
1545 #[allow(dead_code)]
1546 pub yolo: bool,
1547 /// One-shot YOLO→Act+Bypass migration notice for this session (#0.8.68 M6).
1548 yolo_compat_notified: bool,
1549 /// The single serialized owner of `settings.toml` startup-default writes
1550 /// (mode, thinking, model). Keeping one owner per `App` is what stops two
1551 /// rapid selections from interleaving their load/modify/save transactions
1552 /// and losing the newer one. Failures are drained by the event loop into a
1553 /// warning toast, so a settings write that did not land is never silently
1554 /// reverted on the next launch.
1555 pub startup_defaults: crate::tui::startup_defaults::StartupDefaultsWriter,
1556 /// One-shot Shift+Tab/Ctrl+T rebinding notice for this session (#0.8.68 M3).
1557 keybinding_migration_notified: bool,
1558 /// Durable Agent-era permission baseline that Plan/YOLO derive from and
1559 /// restore to (#3386). Refreshed from the live fields whenever the user
1560 /// leaves Agent mode; see [`base_policy_for_mode`] and `set_mode`.
1561 mode_prefs: ModeSessionPrefs,
1562 /// True when config/requirements supplied an approval policy. In that
1563 /// case the TUI-only Shift+Tab preference must not loosen it.
1564 approval_policy_locked: bool,
1565 /// True only when the controlling policy is the user's editable root
1566 /// config.toml key. An explicit Shift+Tab may migrate that key to the
1567 /// durable TUI posture; higher-precedence sources remain immutable.
1568 approval_policy_root_editable: bool,
1569 /// True only when an organization requirements file owns approval policy.
1570 /// Unlike a user-owned config key, this source cannot be edited in-app.
1571 approval_policy_requirements_managed: bool,
1572 // Clipboard handler
1573 pub clipboard: ClipboardHandler,
1574 // Tool approval session allowlist
1575 pub approval_session_approved: HashSet<String>,
1576 /// Approval keys (or tool names) the user has denied or aborted in
1577 /// this session. Subsequent re-requests for the same approval key
1578 /// auto-deny without re-prompting (#360) — the model can retry a
1579 /// dangerous command after being told no, but the user shouldn't
1580 /// have to keep dismissing the same dialog.
1581 pub approval_session_denied: HashSet<String>,
1582 pub approval_mode: ApprovalMode,
1583 // Modal view stack (approval/help/etc.)
1584 pub view_stack: ViewStack,
1585 /// Last `request_user_input` prompt, retained so a failed modal submit can reopen (#1198).
1586 pub pending_user_input_prompt: Option<(String, crate::tools::user_input::UserInputRequest)>,
1587 /// Esc-Esc backtrack state machine (#133). `Inactive` by default; first
1588 /// Esc primes, second Esc opens the live-transcript overlay scoped to
1589 /// previous user messages so the user can rewind a turn.
1590 pub backtrack: crate::tui::backtrack::BacktrackState,
1591 /// Current session ID for auto-save updates
1592 pub current_session_id: Option<String>,
1593 /// Last non-contended Work snapshot captured in this App. The outer
1594 /// option distinguishes "never captured" from a captured empty state.
1595 pub(crate) last_known_work_state: Option<Option<SessionWorkState>>,
1596 /// Metadata for the active session, cached in memory so automatic
1597 /// checkpoints never synchronously reload and parse a growing JSON file on
1598 /// the UI thread.
1599 pub(crate) current_session_metadata: Option<SessionMetadata>,
1600 /// Metadata-only registry of large tool outputs produced in this session.
1601 pub session_artifacts: Vec<ArtifactRecord>,
1602 /// Trust mode - allow access outside workspace
1603 pub trust_mode: bool,
1604 /// Translation mode — when enabled, the model is instructed to respond in
1605 /// the current locale and a post-hoc translation layer replaces any
1606 /// remaining English output before it reaches the user.
1607 pub translation_enabled: bool,
1608 /// Ordered list of footer items the user wants visible. Sourced from
1609 /// `tui.status_items` in `~/.deepseek/config.toml` at startup; mutated
1610 /// live by `/statusline`. The renderer iterates this slice; no item is
1611 /// hardcoded in the footer code path.
1612 pub status_items: Vec<crate::config::StatusItem>,
1613 /// Optional header items enabled from `tui.header_items` in `config.toml`
1614 /// at startup. Built-in header content remains independent of this list.
1615 pub header_items: Vec<crate::config::HeaderItem>,
1616 /// Project documentation (AGENTS.md or CLAUDE.md)
1617 #[allow(dead_code)]
1618 pub project_doc: Option<String>,
1619 /// Plan state for tracking tasks
1620 pub plan_state: SharedPlanState,
1621 /// Todo list for the canonical `work_update` progress surface.
1622 pub todos: SharedTodoList,
1623 /// Durable runtime services exposed to model-visible task/automation tools.
1624 pub runtime_services: RuntimeToolServices,
1625 /// Latest bounded coordination receipt delivered by the engine. This is
1626 /// the same typed projection returned to headless inspection; the TUI does
1627 /// not parse tool text to reconstruct it.
1628 pub coordination_detail: Option<crate::tools::subagent::CoordinationDetailProjection>,
1629 /// Last MCP manager/discovery snapshot shown in the UI.
1630 pub mcp_snapshot: Option<crate::mcp::McpManagerSnapshot>,
1631 /// Number of MCP servers declared in the user's config at app boot.
1632 /// Used by the footer chip (#502) so a count is visible even before
1633 /// the user runs `/mcp` for the first time. `0` hides the chip.
1634 pub mcp_configured_count: usize,
1635 /// Set after in-TUI MCP config edits because the engine caches its MCP pool.
1636 pub mcp_reload_required: bool,
1637 /// Tool execution log
1638 pub tool_log: Vec<String>,
1639 /// Active skill to apply to next user message
1640 pub active_skill: Option<String>,
1641 /// Content-bound plugin authority carried with `active_skill`, when the
1642 /// selected skill came from a reviewed plugin bundle.
1643 pub active_skill_provenance: Option<crate::plugins::types::PluginAuthority>,
1644 /// Cached (name, description) pairs from the skill registry.
1645 /// Populated once at startup and refreshed on install/uninstall so
1646 /// the slash menu can show skills without filesystem I/O on every keystroke.
1647 pub cached_skills: Vec<(String, String)>,
1648 /// Tool call cells by tool id (for cells already finalized in `history`).
1649 /// While a tool call is in flight inside `active_cell`, it is tracked by
1650 /// `active_tool_entries` instead and migrated here at flush time.
1651 pub tool_cells: HashMap<String, usize>,
1652 /// Full tool input/output keyed by history cell index.
1653 pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
1654 /// Linked context references keyed by the visible user history cell that
1655 /// introduced them.
1656 pub context_references_by_cell: HashMap<usize, Vec<SessionContextReference>>,
1657 /// Session-wide context references persisted with saved sessions.
1658 pub session_context_references: Vec<SessionContextReference>,
1659 /// In-flight tool/exec group for the current turn. Mutated in place as
1660 /// parallel tool calls start and complete; flushed into `history` on
1661 /// `TurnComplete`.
1662 pub active_cell: Option<ActiveCell>,
1663 /// Revision counter for `active_cell`. Combined with `active_cell.revision`
1664 /// when feeding the transcript cache so cached lines for the synthetic
1665 /// active-cell row are invalidated on every mutation.
1666 pub active_cell_revision: u64,
1667 /// Pending tool details for entries that live inside `active_cell`.
1668 /// Keyed by tool id rather than cell index because the active cell's
1669 /// virtual index can shift (orphan completions push real cells in
1670 /// between). Migrated into `tool_details_by_cell` on flush.
1671 pub active_tool_details: HashMap<String, ToolDetailRecord>,
1672 /// Completion timestamps for entries still living inside `active_cell`.
1673 /// The transcript keeps completed entries until turn flush, but the
1674 /// sidebar can use these timestamps to let settled live rows expire.
1675 pub active_tool_entry_completed_at: HashMap<usize, Instant>,
1676 /// Active exploring cell entry index (within `active_cell.entries`).
1677 /// `None` once the active cell flushes or no exploring entry exists.
1678 pub exploring_cell: Option<usize>,
1679 /// Mapping of exploring tool ids to `(entry index in active_cell, entry
1680 /// within ExploringCell)`. Used to update individual exploring entries
1681 /// when their tools complete.
1682 pub exploring_entries: HashMap<String, (usize, usize)>,
1683 /// Tool calls that should be ignored by the UI
1684 pub ignored_tool_calls: HashSet<String>,
1685 /// Last exec wait command shown (for duplicate suppression)
1686 pub last_exec_wait_command: Option<String>,
1687 /// Current streaming assistant cell
1688 pub streaming_message_index: Option<usize>,
1689 /// Provenance for append-only changes to the current streaming cell.
1690 /// Revisions are raw `history_revisions`; the widget maps them through its
1691 /// cache-key transform before handing the receipt to the transcript cache.
1692 pub(crate) streaming_source_receipt: Option<crate::tui::transcript::StreamingSourceReceipt>,
1693 /// True after a local cancel key has been handled and before the engine's
1694 /// authoritative TurnComplete arrives. Stream events already queued for
1695 /// the cancelled turn are ignored so text does not keep appearing after
1696 /// Ctrl+C/Esc returns focus to the composer.
1697 pub suppress_stream_events_until_turn_complete: bool,
1698 /// Index into `active_cell.entries` of the thinking entry currently being
1699 /// streamed. `None` when no thinking block is in flight. P2.3 routes
1700 /// thinking into the active cell so it groups visually with tool calls
1701 /// until the next assistant prose chunk flushes the group into history.
1702 pub streaming_thinking_active_entry: Option<usize>,
1703 /// Instant of the last throttled active-cell revision bump for the
1704 /// in-flight thinking stream (#1620). Reasoning chunks arrive faster than
1705 /// the eye can read, and each bump invalidates the active cell's wrap
1706 /// cache, forcing a full re-wrap. We debounce intermediate bumps to a
1707 /// time window so high-frequency thinking deltas no longer trigger a
1708 /// re-render per character. `None` means "no bump since the last
1709 /// finalize" so the first chunk of a block always renders immediately.
1710 pub thinking_revision_last_bump_at: Option<Instant>,
1711 /// Newline-gated streaming collector state.
1712 pub streaming_state: StreamingState,
1713 /// Live approximate output tokens for the current assistant stream.
1714 pub streaming_output_token_estimate: u64,
1715 /// Accumulated reasoning text
1716 pub reasoning_buffer: String,
1717 /// Live reasoning header extracted from bold text
1718 pub reasoning_header: Option<String>,
1719 /// Last completed reasoning block
1720 pub last_reasoning: Option<String>,
1721 /// Tool calls captured for the pending assistant message
1722 pub pending_tool_uses: Vec<(String, String, Value)>,
1723 /// User messages queued while a turn is running
1724 pub queued_messages: VecDeque<QueuedMessage>,
1725 /// Draft queued message being edited
1726 pub queued_draft: Option<QueuedMessage>,
1727 /// Legacy pending-steer bucket retained for session compatibility. New
1728 /// in-flight input uses Ctrl+Enter for same-turn steering and Enter for
1729 /// queued follow-ups; Esc only cancels the active turn.
1730 pub pending_steers: VecDeque<QueuedMessage>,
1731 /// Engine-rejected steers (e.g. a tool was already running and couldn't be
1732 /// cancelled cleanly). Surfaced in the pending-input preview so the user
1733 /// knows the steer was deferred to end-of-turn. Today no engine path
1734 /// produces these; the field is scaffolding for a future signalling
1735 /// channel and the bucket renders with a rejected-steer label when
1736 /// populated.
1737 pub rejected_steers: VecDeque<String>,
1738 /// Legacy resend flag for pending steer recovery.
1739 pub submit_pending_steers_after_interrupt: bool,
1740 /// Start time for current turn
1741 pub turn_started_at: Option<Instant>,
1742 /// Most recent engine event observed for the current turn. This is
1743 /// separate from `turn_started_at` because the latter drives elapsed-time
1744 /// UI and must not be reset during long but healthy turns.
1745 pub turn_last_activity_at: Option<Instant>,
1746 /// Sum of completed turn durations for this `App` instance (#448
1747 /// follow-up). Drives the footer's `worked Nh Mm` chip so the
1748 /// label reflects actual model work, not wall-clock since launch.
1749 /// Incremented on `TurnComplete` from the elapsed time of the
1750 /// just-finished turn. Resets per launch.
1751 pub cumulative_turn_duration: std::time::Duration,
1752 /// DeepSeek account balance, refreshed once per turn completion.
1753 /// Shared cell updated by background fetch tasks; read lock in the UI thread.
1754 pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
1755 /// Shared cell for async fleet-profile model-draft delivery. A background
1756 /// task fills it (model label + drafted profile or a failure reason) so
1757 /// the drafting network call never parks the event loop (#3757 review).
1758 #[allow(clippy::type_complexity)]
1759 /// Monotonic generation for model-draft requests. Bumped on each draft
1760 /// request and each setup/fleet wizard open, so a draft that lands after
1761 /// a superseding request or a wizard reopen is dropped rather than
1762 /// installed into the wrong (or a stale) wizard instance.
1763 pub draft_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
1764 #[allow(clippy::type_complexity)]
1765 pub fleet_draft_cell: std::sync::Arc<
1766 std::sync::Mutex<
1767 Option<(
1768 u64,
1769 String,
1770 // The `(provider, model)` route the operator picked when they
1771 // pressed `m` (#4093). Carried alongside the async draft so the
1772 // ratified profile keeps the picked cross-provider route even if
1773 // the model draft (which is always `provider: None`) omitted or
1774 // changed it. `None` for an `inherit` pick.
1775 Option<(String, String)>,
1776 // The reasoning tier selected when the operator pressed `m`
1777 // (#4137). `None` means inherit.
1778 Option<String>,
1779 Result<Box<crate::fleet::profile::FleetProfileDraft>, String>,
1780 )>,
1781 >,
1782 >,
1783 /// Shared cell for async constitution model-draft delivery (same pattern
1784 /// as `fleet_draft_cell`, so the drafting network call never parks the
1785 /// event loop).
1786 #[allow(clippy::type_complexity)]
1787 pub constitution_draft_cell: std::sync::Arc<
1788 std::sync::Mutex<
1789 Option<(
1790 u64,
1791 String,
1792 crate::localization::Locale,
1793 Result<Box<codewhale_config::UserConstitution>, String>,
1794 )>,
1795 >,
1796 >,
1797 /// Shared cell for async prompt suggestion delivery from background task.
1798 pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
1799 /// Tracks whether the initial balance fetch has been attempted for this session.
1800 pub balance_initiated: bool,
1801 /// Timestamp of the last balance fetch, used to debounce rapid requests.
1802 pub last_balance_fetch: Option<std::time::Instant>,
1803 /// Current runtime turn id (if known).
1804 pub runtime_turn_id: Option<String>,
1805 /// Current runtime turn status (if known).
1806 pub runtime_turn_status: Option<String>,
1807 /// Monotonic turn counter for stable user-facing labels (#3030).
1808 /// Incremented each time a new turn starts; displayed as "Turn N".
1809 pub turn_counter: u64,
1810 /// When the UI accepted a user message but has not observed `TurnStarted` yet.
1811 pub dispatch_started_at: Option<Instant>,
1812
1813 /// Cached git context snapshot for the footer.
1814 pub workspace_context: Option<String>,
1815 /// Shared cell for async git context updates (#399 S1).
1816 pub workspace_context_cell: std::sync::Arc<std::sync::Mutex<Option<String>>>,
1817 /// Timestamp for cached workspace context.
1818 pub workspace_context_refreshed_at: Option<Instant>,
1819 /// Cached size of the memory file, formatted for the Session sidebar.
1820 ///
1821 /// Rendered every frame the Session/Context panel is visible, so the
1822 /// `stat` behind it is refreshed on the workspace-context TTL tick
1823 /// instead of inside the draw closure (#3908) — tens of ms per frame on
1824 /// NFS/SSHFS/cloud-synced homes otherwise.
1825 pub memory_size_hint: Option<String>,
1826 /// Cached background tasks for sidebar rendering.
1827 pub task_panel: Vec<TaskPanelEntry>,
1828 /// Session-local quieting and command detectors for event-driven tips.
1829 pub behavioral_tips: crate::tui::behavioral_tips::BehavioralTipState,
1830 /// Active decision card (v0.8.43 truth-surface). When set, keyboard input
1831 /// is routed through the card navigation instead of the composer.
1832 pub decision_card: Option<crate::tui::widgets::decision_card::DecisionCard>,
1833 /// Unified Workflow activity surface (#4121). Lives above the composer so
1834 /// phase/row progress does not flood the chat transcript. Preserved after
1835 /// completion until the next `RunStarted` replaces it.
1836 pub workflow_panel: Option<crate::tui::widgets::workflow_panel::WorkflowPanel>,
1837 /// Wall-clock time when this TUI session started. Used by the Work
1838 /// sidebar projection to hide completed durable tasks that finished
1839 /// before the current session (bug #1913).
1840 pub session_started_at: chrono::DateTime<chrono::Utc>,
1841 /// Whether the UI needs to be redrawn.
1842 pub needs_redraw: bool,
1843 /// When true, the next draw will be a full repaint (terminal clear +
1844 /// all cells redrawn) instead of a ratatui incremental diff. Used by
1845 /// theme switches where the diff engine may miss color-only changes
1846 /// in sidebar cells that were previously rendered with palette constants.
1847 pub force_next_full_repaint: bool,
1848 /// When the current thinking block started (for duration tracking).
1849 pub thinking_started_at: Option<Instant>,
1850 /// Whether context compaction is currently in progress.
1851 pub is_compacting: bool,
1852 /// Whether context purge is currently in progress.
1853 pub is_purging: bool,
1854 /// Set when the user scrolls up/down during a streaming turn so subsequent
1855 /// streamed chunks don't yank the view back to the live tail. Cleared
1856 /// when the user explicitly returns to bottom or the turn completes.
1857 pub user_scrolled_during_stream: bool,
1858 /// Timestamp of the last user message send (for brief visual feedback).
1859 pub last_send_at: Option<Instant>,
1860 /// Most recent user prompt accepted for an active engine turn. Ctrl+C can
1861 /// restore this into an empty composer after cancelling that turn.
1862 pub last_submitted_prompt: Option<String>,
1863 /// Startup prompt should be submitted automatically after the engine is ready.
1864 pub auto_submit_initial_input: bool,
1865 /// Two-tap quit confirmation. When set, a prior Ctrl+C in idle state has
1866 /// armed the quit shortcut; a second Ctrl+C before this `Instant` exits
1867 /// the app, while expiry silently re-arms the prompt for next time.
1868 /// Stays `None` while a turn is in flight or a modal/picker is open so
1869 /// Ctrl+C keeps its current "interrupt this turn" semantics in those
1870 /// states. See [`App::arm_quit`] / [`App::quit_is_armed`].
1871 pub quit_armed_until: Option<Instant>,
1872
1873 // === Prefix-Cache Stability Tracking ===
1874 /// Number of times the prefix (system prompt + tool specs) has changed.
1875 pub prefix_change_count: u64,
1876 /// Total number of prefix stability checks performed.
1877 pub prefix_checks_total: u64,
1878 /// Current prefix stability percentage, if known.
1879 pub prefix_stability_pct: Option<u32>,
1880 /// Description of the last prefix change, if any.
1881 pub last_prefix_change_desc: Option<String>,
1882 /// Current pinned prefix combined hash (SHA-256, 64 hex chars).
1883 /// Updated per-turn via PrefixCacheChange events; surfaced by
1884 /// `/cache stats` for cache-hit debugging.
1885 pub last_pinned_prefix_hash: Option<String>,
1886
1887 // === Transcript filtering (#397) ===
1888 /// Transcript cells the user has collapsed (hidden from view).
1889 /// Stores **original** virtual cell indices (pre-filtering).
1890 pub collapsed_cells: HashSet<usize>,
1891 /// Thinking cells the user has folded (showing summary instead of full
1892 /// content). Stores **original** virtual cell indices. Toggled by Space
1893 /// when the composer is empty and the cursor is on a thinking cell.
1894 pub folded_thinking: HashSet<usize>,
1895 /// Mapping from filtered cell index → original virtual index.
1896 /// Populated during `ChatWidget::new` by filtering out collapsed cells.
1897 /// Used by `build_context_menu_entries` to convert line-meta indices
1898 /// back to original indices for the `HideCell` / `ShowCell` actions.
1899 pub collapsed_cell_map: Vec<usize>,
1900
1901 /// Whether `/edit` has loaded the last user message into the composer and
1902 /// the next submit should replace (not append to) the last exchange.
1903 pub edit_in_progress: bool,
1904
1905 /// Whether LSP diagnostics are currently enabled. Mirrors the config file
1906 /// `[lsp].enabled` setting. Toggled at runtime via `/lsp on|off`.
1907 pub lsp_enabled: bool,
1908 /// Current-turn LSP repair-loop summary for Ctrl-O Turn Inspector (#4107).
1909 pub lsp_repair: LspRepairState,
1910 /// Derived title for the current session shown in the composer border.
1911 /// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded.
1912 pub session_title: Option<String>,
1913
1914 /// Post-turn receipt rendered as transient composer chrome.
1915 /// Set when a turn completes; cleared when a new turn starts or after expiry.
1916 pub receipt_text: Option<String>,
1917 pub receipt_started_at: Option<Instant>,
1918 /// Tool evidence collected during the current turn for the receipt.
1919 pub tool_evidence: Vec<ToolEvidence>,
1920 }
1921
1922 pub(crate) struct ToolRunCache {
1923 pub(crate) history_version: u64,
1924 pub(crate) active_cell_revision: u64,
1925 pub(crate) active_len: usize,
1926 pub(crate) threshold: usize,
1927 pub(crate) mode: ToolCollapseMode,
1928 pub(crate) calm_mode: bool,
1929 pub(crate) runs: Vec<crate::tui::history::ToolRun>,
1930 }
1931
1932 impl Default for ToolRunCache {
1933 fn default() -> Self {
1934 Self {
1935 history_version: u64::MAX,
1936 active_cell_revision: u64::MAX,
1937 active_len: usize::MAX,
1938 threshold: usize::MAX,
1939 mode: ToolCollapseMode::Expanded,
1940 calm_mode: false,
1941 runs: Vec::new(),
1942 }
1943 }
1944 }
1945
1946 // === Deref to ComposerState for backward compat ===
1947
1948 impl std::ops::Deref for App {
1949 type Target = ComposerState;
1950 fn deref(&self) -> &Self::Target {
1951 &self.composer
1952 }
1953 }
1954
1955 impl std::ops::DerefMut for App {
1956 fn deref_mut(&mut self) -> &mut Self::Target {
1957 &mut self.composer
1958 }
1959 }
1960
1961 // === App State ===
1962
1963 fn default_composer_arrows_scroll(use_mouse_capture: bool) -> bool {
1964 default_composer_arrows_scroll_for_platform(use_mouse_capture, cfg!(windows))
1965 }
1966
1967 fn default_composer_arrows_scroll_for_platform(use_mouse_capture: bool, _is_windows: bool) -> bool {
1968 !use_mouse_capture
1969 }
1970
1971 fn push_enabled_provider_model(
1972 enabled: &mut HashMap<String, Vec<String>>,
1973 provider: &str,
1974 model: &str,
1975 ) {
1976 let provider = provider.trim();
1977 let model = model.trim();
1978 if provider.is_empty() || model.is_empty() || model.eq_ignore_ascii_case("auto") {
1979 return;
1980 }
1981 let models = enabled.entry(provider.to_string()).or_default();
1982 if !models
1983 .iter()
1984 .any(|existing| existing.eq_ignore_ascii_case(model))
1985 {
1986 models.push(model.to_string());
1987 }
1988 }
1989
1990 impl App {
1991 /// Persist the pending session route as the explicit choice (`/fleet
1992 /// save`, `/fleet save-as`, `/model save-default`). Returns the receipt
1993 /// message naming the exact file written — or an error message when the
1994 /// write failed. Nothing is ever written without this explicit call.
1995 pub fn apply_route_save_choice(
1996 &mut self,
1997 choice: crate::tui::views::route_save_prompt::RouteSaveChoice,
1998 ) -> String {
1999 use crate::fleet::store::{FleetFile, FleetOperator, save_fleet, set_selected};
2000 use crate::tui::views::route_save_prompt::RouteSaveChoice;
2001 let Some(pending) = self.pending_route_save.take() else {
2002 return "No pending route change to save.".to_string();
2003 };
2004 let route = format!("{}/{}", pending.provider_identity, pending.model);
2005 match choice {
2006 RouteSaveChoice::UpdateFleet => {
2007 let Some((name, scope)) = pending.fleet.clone() else {
2008 return "Nothing to update — no Fleet is selected. Use /fleet save-as to \
2009 save this route as a new Fleet."
2010 .to_string();
2011 };
2012 match crate::fleet::store::load_fleet_in_scope(&name, scope, &self.workspace) {
2013 Ok((mut fleet, _source_path)) => {
2014 fleet.operator = Some(FleetOperator {
2015 provider: pending.provider_identity.clone(),
2016 model: pending.model.clone(),
2017 reasoning: fleet.operator.as_ref().and_then(|op| op.reasoning.clone()),
2018 });
2019 match save_fleet(&fleet, scope, &self.workspace) {
2020 Ok(path) => format!(
2021 "Fleet `{}` now runs on {route} — wrote {}",
2022 fleet.name,
2023 path.display()
2024 ),
2025 Err(err) => format!("Fleet update failed: {err}"),
2026 }
2027 }
2028 Err(err) => format!(
2029 "Fleet update failed: {err} — the saved Fleet may have moved. Use \
2030 /fleet save-as to persist the route."
2031 ),
2032 }
2033 }
2034 RouteSaveChoice::SaveAsNewFleet => {
2035 let display = format!(
2036 "{} {}",
2037 crate::config::ApiProvider::parse(&pending.provider_identity)
2038 .map(|p| p.display_name().to_string())
2039 .unwrap_or_else(|| pending.provider_identity.clone()),
2040 pending.model
2041 );
2042 let Ok(mut fleet) = FleetFile::new(
2043 display.clone(),
2044 Some("Saved from a session route choice.".to_string()),
2045 ) else {
2046 return "Could not create the Fleet.".to_string();
2047 };
2048 fleet.operator = Some(FleetOperator {
2049 provider: pending.provider_identity.clone(),
2050 model: pending.model.clone(),
2051 reasoning: None,
2052 });
2053 match save_fleet(
2054 &fleet,
2055 crate::fleet::store::FleetScope::Personal,
2056 &self.workspace,
2057 ) {
2058 Ok(path) => {
2059 let selected_note = match set_selected(
2060 &display,
2061 crate::fleet::store::FleetScope::Personal,
2062 &self.workspace,
2063 ) {
2064 Ok(sel_path) => format!(
2065 " — selected as your user-global default; wrote {}",
2066 sel_path.display()
2067 ),
2068 Err(err) => format!(" — selection failed: {err}"),
2069 };
2070 format!(
2071 "Saved route {route} as new Fleet `{}` — wrote {}{selected_note}",
2072 display,
2073 path.display()
2074 )
2075 }
2076 Err(err) => format!("Save failed: {err}"),
2077 }
2078 }
2079 RouteSaveChoice::SaveAsDefault => {
2080 persist_route_as_startup_default(&pending.provider_identity, &pending.model)
2081 }
2082 RouteSaveChoice::SessionOnly => {
2083 format!("Route {route} kept for this session only — nothing was written.")
2084 }
2085 }
2086 }
2087
2088 /// Persist the route this session is *actually* running as the startup
2089 /// default.
2090 ///
2091 /// This reads the live route rather than [`Self::pending_route_save`] on
2092 /// purpose. The pending record is bookkeeping for the save *prompt*, and it
2093 /// is written by several different paths (same-provider apply, cross-
2094 /// provider `switch_provider`, `/model`). Cross-checking it before an
2095 /// explicit "make this my default" action meant that any ordering
2096 /// disagreement dropped the write with no error shown — the user saw a
2097 /// normal "Model: x → y" line and reasonably assumed it had stuck, then the
2098 /// next launch reopened the old route. An explicit request now always
2099 /// reports what it did.
2100 pub fn save_live_route_as_startup_default(&mut self) -> String {
2101 let provider_identity = self.provider_identity_for_persistence().to_string();
2102 let model = if self.auto_model {
2103 "auto".to_string()
2104 } else {
2105 self.model.clone()
2106 };
2107 // This explicit decision resolves the pending prompt.
2108 self.pending_route_save = None;
2109 persist_route_as_startup_default(&provider_identity, &model)
2110 }
2111
2112 /// Record that the live session route changed to `provider_identity` /
2113 /// `model`. The change is temporary until the user explicitly chooses how
2114 /// to save it; nothing is written here.
2115 pub fn note_session_route_change(&mut self, provider_identity: &str, model: &str) {
2116 let fleet =
2117 crate::fleet::store::selected_fleet(&self.workspace).map(|sel| (sel.name, sel.scope));
2118 self.pending_route_save = Some(PendingRouteSave {
2119 provider_identity: provider_identity.to_string(),
2120 model: model.to_string(),
2121 fleet,
2122 });
2123 }
2124
2125 /// One truthful chip for cumulative session cost surfaces.
2126 ///
2127 /// Session history wins over the *current* route: switching to an OAuth or
2128 /// local route must not hide spend already accrued on a metered route, and
2129 /// an unpriced turn turns a displayed amount into a subtotal rather than a
2130 /// complete total.
2131 #[must_use]
2132 pub fn cumulative_usage_chip(&self) -> crate::route_billing::UsageChip {
2133 let displayed = self.displayed_session_cost_for_currency(self.cost_currency);
2134 let (priced, unpriced) = match self.cost_display_currency(self.cost_currency) {
2135 CostCurrency::Usd => (
2136 self.session.cost_priced_turns,
2137 self.session.cost_unpriced_turns,
2138 ),
2139 CostCurrency::Cny => (
2140 self.session.cost_cny_priced_turns,
2141 self.session.cost_cny_unpriced_turns,
2142 ),
2143 };
2144 if self.session.cost_coverage_unknown_legacy {
2145 return if displayed.is_finite() && displayed > 0.0 {
2146 crate::route_billing::UsageChip::PricedSubtotal {
2147 amount: self.format_cost_amount(displayed),
2148 legacy: true,
2149 }
2150 } else {
2151 crate::route_billing::UsageChip::Unknown
2152 };
2153 }
2154 if unpriced > 0 {
2155 return if displayed.is_finite() && displayed > 0.0 {
2156 crate::route_billing::UsageChip::PricedSubtotal {
2157 amount: self.format_cost_amount(displayed),
2158 legacy: false,
2159 }
2160 } else {
2161 crate::route_billing::UsageChip::Unknown
2162 };
2163 }
2164 if priced > 0 {
2165 return if displayed.is_finite() && displayed > 0.0 {
2166 crate::route_billing::UsageChip::Money(self.format_cost_amount(displayed))
2167 } else {
2168 crate::route_billing::UsageChip::Hidden
2169 };
2170 }
2171 crate::route_billing::usage_chip(
2172 self.billing_presentation,
2173 self.api_provider,
2174 &self.model,
2175 displayed,
2176 self.cost_display_currency(self.cost_currency),
2177 None,
2178 )
2179 }
2180
2181 pub fn enable_provider_model(&mut self, provider: &str, model: &str) {
2182 push_enabled_provider_model(&mut self.enabled_provider_models, provider, model);
2183 }
2184
2185 #[must_use]
2186 pub fn provider_model_is_enabled(&self, provider: &str, model: &str) -> bool {
2187 self.enabled_provider_models
2188 .get(provider)
2189 .is_some_and(|models| {
2190 models
2191 .iter()
2192 .any(|enabled| enabled.eq_ignore_ascii_case(model))
2193 })
2194 }
2195
2196 /// Advance and return the model-draft generation. Call when a draft is
2197 /// requested or a setup/fleet wizard opens; a spawned draft that captured
2198 /// an older generation is dropped on delivery.
2199 pub fn next_draft_gen(&self) -> u64 {
2200 self.draft_gen
2201 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
2202 + 1
2203 }
2204
2205 /// The current model-draft generation (delivery compares against this).
2206 #[must_use]
2207 pub fn current_draft_gen(&self) -> u64 {
2208 self.draft_gen.load(std::sync::atomic::Ordering::SeqCst)
2209 }
2210
2211 /// Cap on the session turn-cache history. Holds enough turns to debug a long
2212 /// session without being so large the on-screen `/cache` table wraps.
2213 pub const TURN_CACHE_HISTORY_CAP: usize = 50;
2214
2215 /// Append a per-turn cache-telemetry record, trimming the oldest entry once
2216 /// the ring exceeds [`Self::TURN_CACHE_HISTORY_CAP`].
2217 pub fn push_turn_cache_record(&mut self, record: TurnCacheRecord) {
2218 self.session.turn_cache_history.push_back(record);
2219 while self.session.turn_cache_history.len() > Self::TURN_CACHE_HISTORY_CAP {
2220 self.session.turn_cache_history.pop_front();
2221 }
2222 }
2223
2224 pub(crate) fn clear_model_scoped_telemetry(&mut self) {
2225 self.session.last_prompt_tokens = None;
2226 self.session.last_completion_tokens = None;
2227 self.session.last_output_throughput = None;
2228 self.session.last_prompt_cache_hit_tokens = None;
2229 self.session.last_prompt_cache_miss_tokens = None;
2230 self.session.last_reasoning_replay_tokens = None;
2231 self.session.turn_cache_history.clear();
2232 self.pending_turn_route = None;
2233 self.pending_auto_route_receipt = None;
2234 self.active_turn = None;
2235 self.last_effective_model = None;
2236 self.last_effective_provider = None;
2237 self.last_effective_provider_identity = None;
2238 self.last_auto_route_receipt = None;
2239 self.last_pinned_prefix_hash = None;
2240 }
2241
2242 /// Invalidate facts that were accepted under the previous reasoning
2243 /// request.
2244 ///
2245 /// A fixed model keeps the same concrete route when its reasoning tier
2246 /// changes, so only its effective-reasoning receipt becomes stale. Under
2247 /// Auto, reasoning is one of the classifier inputs; the previous concrete
2248 /// provider/model route therefore cannot be replayed or displayed as the
2249 /// route for the new request.
2250 pub(crate) fn invalidate_route_receipts_for_reasoning_change(&mut self) {
2251 self.last_effective_reasoning_effort = None;
2252 if self.auto_model {
2253 self.last_effective_model = None;
2254 self.last_effective_provider = None;
2255 self.last_effective_provider_identity = None;
2256 self.last_auto_route_receipt = None;
2257 }
2258 }
2259
2260 pub fn tr(&self, id: MessageId) -> Cow<'static, str> {
2261 tr(self.ui_locale, id)
2262 }
2263
2264 fn discover_cached_skills(
2265 workspace: &std::path::Path,
2266 skills_dir: &std::path::Path,
2267 scan_codewhale_only: bool,
2268 plugins: &crate::plugins::PluginRegistry,
2269 ) -> Vec<(String, String)> {
2270 crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
2271 workspace,
2272 skills_dir,
2273 crate::skills::SkillDiscoveryMode::from_codewhale_only(scan_codewhale_only),
2274 Some(plugins),
2275 )
2276 .into_enabled()
2277 .list()
2278 .iter()
2279 .map(|s| (s.name.clone(), s.description.clone()))
2280 .collect()
2281 }
2282
2283 pub fn refresh_skill_cache(&mut self) {
2284 crate::skills::clear_skill_discovery_cache();
2285 let skills_dir = self.skills_dir.clone();
2286 let cached_skills = Self::discover_cached_skills(
2287 &self.workspace,
2288 &skills_dir,
2289 self.skills_scan_codewhale_only,
2290 self.plugin_registry.as_ref(),
2291 );
2292 self.hotbar_actions.replace_skills(&cached_skills);
2293 self.cached_skills = cached_skills;
2294 }
2295
2296 pub fn finish_onboarding_without_feature_intro(&mut self) {
2297 self.onboarding = OnboardingState::None;
2298 if let Err(err) = crate::tui::onboarding::mark_onboarded() {
2299 self.status_message = Some(format!("Failed to mark onboarding: {err}"));
2300 }
2301 self.needs_redraw = true;
2302 }
2303
2304 /// Mark the first-run follow-up as seen without inserting a transcript
2305 /// message. The empty underwater launch surface owns setup guidance; a
2306 /// synthetic history cell would hide that surface before the user sends
2307 /// anything.
2308 pub fn maybe_show_feature_intro(&mut self) {
2309 if self.onboarding != OnboardingState::None {
2310 return;
2311 }
2312 // Never claim "setup is ready" when auth is still missing — e.g.
2313 // `--skip-onboarding` with no API key (#3985). Leave the flag unset so
2314 // the tip can appear after the user finishes provider setup.
2315 if self.onboarding_needs_api_key {
2316 return;
2317 }
2318 // One transaction: the "already shown?" read and the flag write must not
2319 // straddle another writer's whole-file save.
2320 let write = Settings::transact_opt(|settings| {
2321 if settings.feature_intro_shown {
2322 return Ok(None);
2323 }
2324 settings.feature_intro_shown = true;
2325 Ok(Some(()))
2326 });
2327 match write {
2328 Ok(None) => return,
2329 Ok(Some(())) => {}
2330 Err(err) => {
2331 self.status_message = Some(format!("Failed to save feature-intro flag: {err}"));
2332 // Still show the nudge; the flag write may simply retry next launch.
2333 }
2334 }
2335 self.status_message = Some(self.tr(MessageId::FleetReadyNotice).into_owned());
2336 self.needs_redraw = true;
2337 }
2338
2339 /// Apply a locale tag selected from the onboarding language picker (#566).
2340 /// Persists the value to settings.toml and immediately
2341 /// re-resolves `ui_locale` so the rest of onboarding renders in the new
2342 /// language. `App` doesn't keep `Settings` resident — it loads on entry
2343 /// and rewrites on exit, mirroring the pattern used by the `/config`
2344 /// surface.
2345 pub fn set_locale_from_onboarding(&mut self, tag: &str) -> anyhow::Result<()> {
2346 let locale = Settings::transact(|settings| {
2347 settings.set("locale", tag)?;
2348 Ok(settings.locale.clone())
2349 })?;
2350 self.ui_locale = crate::localization::resolve_locale(&locale);
2351 self.needs_redraw = true;
2352 Ok(())
2353 }
2354
2355 /// Locale tag currently persisted in settings.toml (or
2356 /// `"auto"` when no settings file exists). Used by the onboarding
2357 /// language picker to highlight the current selection without `App`
2358 /// having to keep `Settings` resident.
2359 pub fn current_locale_tag(&self) -> String {
2360 Settings::load()
2361 .map(|s| s.locale)
2362 .unwrap_or_else(|_| "auto".to_string())
2363 }
2364
2365 pub fn set_mode(&mut self, mode: AppMode) -> bool {
2366 let requested_mode = mode;
2367 let mode = match mode {
2368 AppMode::Yolo => AppMode::Agent,
2369 other => other,
2370 };
2371 let yolo_compat = requested_mode == AppMode::Yolo;
2372 let previous_mode = self.mode;
2373 if previous_mode == mode && !yolo_compat && !self.yolo {
2374 return false;
2375 }
2376
2377 self.mode = mode;
2378 // Mode chip lives in the header — skip redundant status/toast copy.
2379
2380 // Mode cycling is untangled from permission policy (#3386). The user
2381 // only edits the durable permission surface while in Agent mode, so
2382 // refresh the baseline from the live mirrors whenever we leave Agent —
2383 // before any transient Plan/YOLO policy overwrites them. This subsumes
2384 // the old per-mode `YoloRestoreState`/`PlanRestoreState` snapshots:
2385 // cross-mode hops (Plan -> YOLO, YOLO -> Plan) do not touch the baseline,
2386 // so YOLO's elevated authority never bleeds into the restored Agent
2387 // surface (#3279).
2388 if previous_mode.uses_agent_baseline() && !self.yolo {
2389 self.mode_prefs = ModeSessionPrefs {
2390 agent_allow_shell: self.allow_shell,
2391 agent_trust_mode: self.trust_mode,
2392 agent_approval_mode: self.approval_mode,
2393 };
2394 }
2395
2396 if yolo_compat {
2397 // Transient full-access mirrors for legacy YOLO entry points; do not
2398 // persist trust/shell elevation into the durable Agent baseline.
2399 self.allow_shell = true;
2400 self.trust_mode = true;
2401 self.approval_mode = ApprovalMode::Bypass;
2402 self.yolo = true;
2403 self.notify_yolo_compat_once();
2404 } else {
2405 let policy = base_policy_for_mode(mode, &self.mode_prefs);
2406 self.allow_shell = policy.allow_shell;
2407 self.trust_mode = policy.trust_mode;
2408 self.approval_mode = policy.approval_mode;
2409 self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
2410 }
2411
2412 // Execute mode change hooks. Built from `base_hook_context` so this
2413 // event carries the same session id, workspace, model, and token total
2414 // as every other event — it used to omit `DEEPSEEK_SESSION_ID`
2415 // entirely, which made mode transitions uncorrelatable with the
2416 // session they belonged to.
2417 let context = self
2418 .base_hook_context()
2419 .with_mode(mode.label())
2420 .with_previous_mode(previous_mode.label());
2421 if let Err(error) = self.submit_hooks(HookEvent::ModeChange, context) {
2422 self.surface_observer_hook_submission_failure(error);
2423 }
2424 self.needs_redraw = true;
2425 true
2426 }
2427
2428 /// Apply a *user-facing* mode selection: change the live session mode and
2429 /// persist it as the startup default.
2430 ///
2431 /// This is the difference between [`Self::set_mode`] and this method.
2432 /// `set_mode` is the session-only primitive — session restore and preset
2433 /// application use it because they are re-installing a mode the user
2434 /// already chose elsewhere, and re-persisting there would let a restored
2435 /// session silently rewrite the startup default. Every interactive
2436 /// selector (Tab/Shift+Tab cycling, the Alt+A/P/Y shortcuts, the hotbar
2437 /// mode actions) goes through here instead, so "I switched to Operate"
2438 /// survives a restart (reported by Hunter against v0.9.1).
2439 ///
2440 /// The write is queued, not performed here: it is ordered behind every
2441 /// earlier selection by [`StartupDefaultsWriter`], and a failure surfaces
2442 /// through [`Self::drain_startup_default_failures`] rather than being
2443 /// dropped.
2444 ///
2445 /// What is persisted is `self.mode` — the mode `set_mode` actually
2446 /// installed — not the requested enum. The legacy `Yolo` entry point installs
2447 /// Act, so persisting the request would write a startup mode the user never
2448 /// lands in. `AppMode::as_setting` collapses that alias too, but reading the
2449 /// installed value keeps the two from having to agree.
2450 ///
2451 /// The outcome is typed, not a bool, because three things can happen and
2452 /// only one of them means "nothing was saved":
2453 ///
2454 /// - [`SettingSelection::Changed`] — live mode moved *and* the startup
2455 /// default was queued.
2456 /// - [`SettingSelection::PersistedSame`] — live mode was already the
2457 /// requested one, but the startup default was still queued. This is a
2458 /// real, reportable action: after a session restore the live mode and the
2459 /// startup default routinely disagree.
2460 /// - [`SettingSelection::Refused`] — the #2982 turn lock rejected it and
2461 /// nothing was written anywhere.
2462 ///
2463 /// A bool collapsed the last two, so every caller (slash `/mode`, the
2464 /// Alt+A/P/Y shortcuts, the hotbar mode rows) reported a refusal and a
2465 /// successful same-mode save identically — as "already in that mode", with
2466 /// no receipt for the write that did happen.
2467 ///
2468 /// [`StartupDefaultsWriter`]: crate::tui::startup_defaults::StartupDefaultsWriter
2469 pub fn select_mode(&mut self, mode: AppMode) -> SettingSelection {
2470 if self.reject_setting_change_while_busy(MessageId::SettingSubjectMode) {
2471 return SettingSelection::Refused;
2472 }
2473 let changed = self.set_mode(mode);
2474 // Persist an explicit selection even when it matches the live mode.
2475 // A restored session can be Operate while the startup default remains
2476 // Act; choosing Operate again is a request to make the visible state
2477 // durable, not a no-op.
2478 self.startup_defaults
2479 .spawn(crate::tui::startup_defaults::StartupDefaults::mode(
2480 self.mode,
2481 ));
2482 if changed {
2483 SettingSelection::Changed
2484 } else {
2485 SettingSelection::PersistedSame
2486 }
2487 }
2488
2489 /// The receipt for an accepted selection that did not move live state.
2490 ///
2491 /// Without it a same-live selection is indistinguishable from a refusal on
2492 /// screen, even though it wrote the file the user was trying to change.
2493 #[must_use]
2494 pub fn mode_startup_default_receipt(&self, mode: AppMode) -> String {
2495 self.tr(MessageId::ModeAlreadyActiveSavedAsDefault)
2496 .replace("{mode}", mode.display_name())
2497 }
2498
2499 /// Surface any startup-default write that failed since the last drain.
2500 /// Called once per event-loop iteration.
2501 pub fn drain_startup_default_failures(&mut self) {
2502 for failure in self.startup_defaults.drain_failures() {
2503 let message = self.startup_default_failure_message(&failure);
2504 self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
2505 }
2506 }
2507
2508 /// Translate a typed startup-default failure at the locale boundary.
2509 ///
2510 /// The writer runs on a blocking pool and knows nothing about the user's
2511 /// locale, so it reports `StartupDefaultSubject` values and a path-free
2512 /// detail. Turning those into a sentence is this side's job.
2513 #[must_use]
2514 pub fn startup_default_failure_message(
2515 &self,
2516 failure: &crate::tui::startup_defaults::StartupDefaultFailure,
2517 ) -> String {
2518 use crate::tui::startup_defaults::StartupDefaultSubject;
2519
2520 let subject = if failure.subjects.is_empty() {
2521 self.tr(MessageId::StartupDefaultSubjectAll).into_owned()
2522 } else {
2523 failure
2524 .subjects
2525 .iter()
2526 .map(|subject| {
2527 self.tr(match subject {
2528 StartupDefaultSubject::Mode => MessageId::StartupDefaultSubjectMode,
2529 StartupDefaultSubject::Thinking => MessageId::StartupDefaultSubjectThinking,
2530 StartupDefaultSubject::Model => MessageId::StartupDefaultSubjectModel,
2531 })
2532 .into_owned()
2533 })
2534 .collect::<Vec<_>>()
2535 // A separator, not a word: composed in code per the crate's
2536 // localization rules.
2537 .join(" + ")
2538 };
2539 self.tr(MessageId::StartupDefaultNotSaved)
2540 .replace("{setting}", &subject)
2541 .replace("{error}", &failure.detail)
2542 }
2543
2544 fn notify_yolo_compat_once(&mut self) {
2545 if self.yolo_compat_notified {
2546 return;
2547 }
2548 self.yolo_compat_notified = true;
2549 // Per-install suppression: check the persisted flag so the toast
2550 // appears exactly once across sessions, not every launch.
2551 if let Ok(settings) = crate::settings::Settings::load()
2552 && settings.yolo_deprecation_shown
2553 {
2554 return;
2555 }
2556 // Persist the flag best-effort; toast still fires even if the write
2557 // fails (retries on the next attempt).
2558 let _ = crate::settings::Settings::transact(|settings| {
2559 settings.yolo_deprecation_shown = true;
2560 Ok(())
2561 });
2562 self.push_status_toast(
2563 "Legacy full-access mode is deprecated — use Act + Full Access (Shift+Tab)".to_string(),
2564 StatusToastLevel::Warning,
2565 Some(8_000),
2566 );
2567 }
2568
2569 /// One-release migration notice for the Shift+Tab/Ctrl+T rebinding: users
2570 /// pressing Shift+Tab expecting the old thinking cycle land here first.
2571 fn notify_keybinding_migration_once(&mut self) {
2572 if self.keybinding_migration_notified {
2573 return;
2574 }
2575 self.keybinding_migration_notified = true;
2576 self.push_status_toast(
2577 "Shift+Tab now cycles permissions — reasoning effort moved to Ctrl+T".to_string(),
2578 StatusToastLevel::Info,
2579 Some(8_000),
2580 );
2581 }
2582
2583 /// Whether mode/thinking selection is locked because a turn is in flight.
2584 ///
2585 /// While `is_loading`, the model/permission surface the engine is acting on
2586 /// must not shift underneath it, so user-initiated mode and thinking changes
2587 /// are refused (#2982). Returns true (and posts a concise status message) if
2588 /// the change should be rejected — the caller leaves the selection unchanged
2589 /// so the chip "twitches" back instead of moving.
2590 ///
2591 /// `subject` is a `MessageId`, not a `&str`, so the refusal is translated
2592 /// as one sentence in the user's locale instead of splicing an English noun
2593 /// into a translated template.
2594 pub(crate) fn reject_setting_change_while_busy(&mut self, subject: MessageId) -> bool {
2595 if self.is_loading {
2596 let message = self.setting_locked_message(subject);
2597 self.status_message = Some(message);
2598 self.needs_redraw = true;
2599 true
2600 } else {
2601 false
2602 }
2603 }
2604
2605 /// The localized "locked while a turn is running" sentence for `subject`.
2606 #[must_use]
2607 pub(crate) fn setting_locked_message(&self, subject: MessageId) -> String {
2608 self.tr(MessageId::SettingLockedDuringTurn)
2609 .replace("{setting}", self.tr(subject).as_ref())
2610 }
2611
2612 /// Cycle through productive modes: Plan → Act → Operate → Plan.
2613 pub fn cycle_mode(&mut self) {
2614 let next = self.mode.next();
2615 let outcome = self.select_mode(next);
2616 self.report_mode_selection(next, outcome);
2617 }
2618
2619 /// Cycle through modes in reverse.
2620 #[allow(dead_code)]
2621 pub fn cycle_mode_reverse(&mut self) {
2622 let next = self.mode.previous();
2623 let outcome = self.select_mode(next);
2624 self.report_mode_selection(next, outcome);
2625 }
2626
2627 /// Show the startup-default receipt for a selection that did not move live
2628 /// mode. `Changed` and `Refused` already have their own messaging (the mode
2629 /// chip, and `reject_setting_change_while_busy` respectively).
2630 pub(crate) fn report_mode_selection(&mut self, mode: AppMode, outcome: SettingSelection) {
2631 if outcome == SettingSelection::PersistedSame {
2632 let receipt = self.mode_startup_default_receipt(mode);
2633 self.status_message = Some(receipt);
2634 self.needs_redraw = true;
2635 }
2636 }
2637
2638 /// Cycle reasoning-effort through the active route's distinct tiers.
2639 ///
2640 /// Typed for the same reason as [`Self::select_mode`]: a bool could not tell
2641 /// the hotbar whether the turn lock refused the action or the provider
2642 /// simply exposes a single tier.
2643 pub fn cycle_effort(&mut self) -> SettingSelection {
2644 if self.reject_setting_change_while_busy(MessageId::SettingSubjectThinking) {
2645 return SettingSelection::Refused;
2646 }
2647 let previous = self.reasoning_effort;
2648 self.apply_reasoning_effort_cycle();
2649 if self.reasoning_effort == previous {
2650 SettingSelection::PersistedSame
2651 } else {
2652 SettingSelection::Changed
2653 }
2654 }
2655
2656 /// Advance reasoning effort to the next tier for the active route and
2657 /// surface the change: set a status message and refresh the compaction
2658 /// budget. Auto routing retains the full provider-neutral vocabulary until
2659 /// dispatch; a concrete provider uses its distinct supported tiers. Shared
2660 /// by the Ctrl+T shortcut (`cycle_effort`) and the hotbar
2661 /// `reasoning.cycle` action so the two paths cannot drift.
2662 pub(crate) fn apply_reasoning_effort_cycle(&mut self) {
2663 let requested = if self.auto_model {
2664 self.reasoning_effort.cycle_next_for_auto_model()
2665 } else {
2666 self.reasoning_effort
2667 .cycle_next_for_provider(self.api_provider)
2668 };
2669 let effective = self.effective_reasoning_effort_for_active_route(requested);
2670 let route_truth = self.active_reasoning_route_truth();
2671 let provider_kind = route_truth.map_or(self.api_provider, |(provider, _, _, _)| provider);
2672 let provider = route_truth.map_or_else(
2673 || self.provider_identity_for_persistence().to_string(),
2674 |(_, provider_identity, _, _)| provider_identity.to_string(),
2675 );
2676 let endpoint_identity = route_truth
2677 .map(|(_, _, endpoint, _)| crate::route_receipt::endpoint_identity(endpoint));
2678 let model = route_truth.map(|(_, _, _, model)| model.to_string());
2679 if let Some(work) = self.runtime_services.work.clone()
2680 && let Err(err) = work.record_reasoning_effort_change(
2681 self.current_session_id.as_deref(),
2682 requested.into(),
2683 effective.into(),
2684 provider_kind,
2685 &provider,
2686 endpoint_identity.as_deref(),
2687 model.as_deref(),
2688 )
2689 {
2690 self.status_message = Some(format!(
2691 "Reasoning effort unchanged: Work receipt failed ({err})"
2692 ));
2693 self.needs_redraw = true;
2694 return;
2695 }
2696 self.reasoning_effort = requested;
2697 self.reasoning_effort_preference = Some(requested);
2698 self.invalidate_route_receipts_for_reasoning_change();
2699 // Same persistence owner as the model/effort pickers, so Ctrl+T and the
2700 // hotbar `reasoning.cycle` action restore on restart exactly like a
2701 // picker selection does. Only the *requested* tier is persisted — the
2702 // effective tier is a per-turn route fact, not a user preference.
2703 self.startup_defaults.spawn(
2704 crate::tui::startup_defaults::StartupDefaults::reasoning_effort(requested.as_setting()),
2705 );
2706 self.update_model_compaction_budget();
2707 self.status_message = Some(format!(
2708 "Reasoning effort: {}",
2709 Self::reasoning_effort_resolution_label(requested, effective, self.api_provider)
2710 ));
2711 self.needs_redraw = true;
2712 }
2713
2714 /// Cycle the durable Agent permission posture: Ask → Auto-Review → Bypass.
2715 pub fn cycle_approval_posture(&mut self) -> bool {
2716 let Some(next) = self.next_approval_posture(false) else {
2717 return false;
2718 };
2719 if self.approval_policy_locked() {
2720 self.push_status_toast(
2721 "Permissions are controlled by config or managed requirements".to_string(),
2722 StatusToastLevel::Warning,
2723 Some(6_000),
2724 );
2725 self.needs_redraw = true;
2726 return false;
2727 }
2728 if let Err(err) = Self::persist_permission_posture(next) {
2729 self.push_status_toast(
2730 format!("Permissions were not changed: could not save TUI posture ({err})"),
2731 StatusToastLevel::Warning,
2732 Some(8_000),
2733 );
2734 self.needs_redraw = true;
2735 return false;
2736 }
2737 self.finish_approval_posture_change(next);
2738 true
2739 }
2740
2741 /// Cycle permissions when the only controlling source is the user's
2742 /// editable root `config.toml` key. Shift+Tab is an explicit request to
2743 /// adopt the TUI posture, so persist the next setting first, then remove
2744 /// the shadowing root key. Roll back the setting if that removal fails.
2745 pub fn cycle_root_approval_posture(&mut self) -> bool {
2746 let Some(next) = self.next_approval_posture(true) else {
2747 return false;
2748 };
2749 if !self.approval_policy_root_editable {
2750 self.push_status_toast(
2751 "Permissions are controlled by a non-editable policy source".to_string(),
2752 StatusToastLevel::Warning,
2753 Some(6_000),
2754 );
2755 self.needs_redraw = true;
2756 return false;
2757 }
2758
2759 if let Err(reason) = self.adopt_root_approval_posture(next) {
2760 self.push_status_toast(
2761 format!("Permissions were not changed: {reason}"),
2762 StatusToastLevel::Warning,
2763 Some(8_000),
2764 );
2765 self.needs_redraw = true;
2766 return false;
2767 }
2768
2769 true
2770 }
2771
2772 /// Save a real TUI permission posture and release the user-owned root
2773 /// `approval_policy` that would otherwise shadow it. This is shared by
2774 /// Shift+Tab and the config choice editor so both surfaces make the same
2775 /// atomic transition from raw policy tokens to the three product postures.
2776 pub(crate) fn adopt_root_approval_posture(&mut self, next: ApprovalMode) -> Result<(), String> {
2777 if !self.approval_policy_root_editable {
2778 return Err("the root approval policy is not editable".to_string());
2779 }
2780
2781 let active_config_path = crate::config::resolve_load_config_path(self.config_path.clone())
2782 .map_err(|error| error.to_string())?;
2783 // The posture commit, the root-key release, and the rollback are one
2784 // critical section. Two `Settings::transact` calls would expose the
2785 // uncommitted middle state — a concurrent writer (a queued startup-default
2786 // drain, say) could load the new posture, and the rollback save would then
2787 // also revert whatever that writer had committed in between.
2788 /// Why the critical section ended, carried out so every toast is
2789 /// pushed after the settings lock is released.
2790 enum RootPostureOutcome {
2791 Committed,
2792 Failed(String),
2793 }
2794
2795 let posture = Self::approval_posture_setting(next).to_string();
2796 let outcome = crate::settings::with_settings_transaction(|transaction| {
2797 let mut settings = match transaction.load() {
2798 Ok(settings) => settings,
2799 Err(err) => {
2800 return Ok(RootPostureOutcome::Failed(format!(
2801 "could not load TUI settings ({err})"
2802 )));
2803 }
2804 };
2805 let previous = settings.permission_posture.clone();
2806 settings.permission_posture = Some(posture);
2807 if let Err(err) = transaction.save(&settings) {
2808 return Ok(RootPostureOutcome::Failed(format!(
2809 "could not save TUI posture ({err})"
2810 )));
2811 }
2812
2813 if let Err(err) = crate::config_persistence::persist_unset_root_key(
2814 active_config_path.as_deref(),
2815 "approval_policy",
2816 ) {
2817 settings.permission_posture = previous;
2818 let rollback_note = transaction
2819 .save(&settings)
2820 .err()
2821 .map(|rollback| format!("; settings rollback also failed: {rollback}"))
2822 .unwrap_or_default();
2823 return Ok(RootPostureOutcome::Failed(format!(
2824 "could not release root config policy ({err}){rollback_note}"
2825 )));
2826 }
2827 Ok(RootPostureOutcome::Committed)
2828 })
2829 .unwrap_or_else(|err| {
2830 RootPostureOutcome::Failed(format!("could not lock TUI settings ({err})"))
2831 });
2832 if let RootPostureOutcome::Failed(reason) = outcome {
2833 return Err(reason);
2834 }
2835
2836 self.clear_saved_approval_policy_lock();
2837 self.finish_approval_posture_change(next);
2838 Ok(())
2839 }
2840
2841 fn next_approval_posture(&mut self, allow_root_policy: bool) -> Option<ApprovalMode> {
2842 if self.reject_setting_change_while_busy(MessageId::SettingSubjectPermissions) {
2843 return None;
2844 }
2845 if self.mode == AppMode::Plan {
2846 self.push_status_toast(
2847 "Plan is Read Only; switch to Act to change permissions".to_string(),
2848 StatusToastLevel::Info,
2849 Some(5_000),
2850 );
2851 self.needs_redraw = true;
2852 return None;
2853 }
2854 if allow_root_policy && !self.approval_policy_root_editable {
2855 return None;
2856 }
2857 Some(self.mode_prefs.agent_approval_mode.cycle_permission_next())
2858 }
2859
2860 fn approval_posture_setting(mode: ApprovalMode) -> &'static str {
2861 match mode {
2862 ApprovalMode::Suggest => "ask",
2863 ApprovalMode::Auto => "auto-review",
2864 ApprovalMode::Bypass => "full-access",
2865 ApprovalMode::Never => "never",
2866 }
2867 }
2868
2869 /// Persist the Shift+Tab permission posture.
2870 ///
2871 /// Synchronous on purpose: `cycle_approval_posture` only moves the live
2872 /// posture if this succeeded, so the keystroke already required the write.
2873 /// It runs inside [`Settings::transact`] so it cannot interleave with a
2874 /// queued mode/thinking write — the two used to load the same bytes and the
2875 /// later save reverted the other's field.
2876 fn persist_permission_posture(next: ApprovalMode) -> anyhow::Result<()> {
2877 Settings::transact(|settings| {
2878 settings.permission_posture = Some(Self::approval_posture_setting(next).to_string());
2879 Ok(())
2880 })
2881 }
2882
2883 fn finish_approval_posture_change(&mut self, next: ApprovalMode) {
2884 self.set_agent_approval_posture(next);
2885 self.needs_redraw = true;
2886 // Footer permission chip is canonical — no status toast for the new
2887 // value, only the one-shot rebinding notice.
2888 self.notify_keybinding_migration_once();
2889 }
2890
2891 /// Replace the complete durable Act baseline and project it onto the live
2892 /// runtime when the current mode uses that baseline. Keeping these three
2893 /// fields together prevents setup presets from updating a live mirror while
2894 /// leaving the next Plan → Act transition stale.
2895 pub fn set_agent_runtime_baseline(
2896 &mut self,
2897 allow_shell: bool,
2898 trust_mode: bool,
2899 approval_mode: ApprovalMode,
2900 ) {
2901 self.mode_prefs = ModeSessionPrefs {
2902 agent_allow_shell: allow_shell,
2903 agent_trust_mode: trust_mode,
2904 agent_approval_mode: approval_mode,
2905 };
2906 if self.mode.uses_agent_baseline() {
2907 let policy = base_policy_for_mode(self.mode, &self.mode_prefs);
2908 self.allow_shell = policy.allow_shell;
2909 self.trust_mode = policy.trust_mode;
2910 self.approval_mode = policy.approval_mode;
2911 self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
2912 }
2913 }
2914
2915 #[must_use]
2916 pub(crate) fn agent_trust_baseline(&self) -> bool {
2917 self.mode_prefs.agent_trust_mode
2918 }
2919
2920 /// Update the durable Act shell choice without disturbing trust or
2921 /// approval. The live mirror changes only while Act owns the runtime.
2922 pub fn set_agent_shell_access(&mut self, allow_shell: bool) {
2923 self.set_agent_runtime_baseline(
2924 allow_shell,
2925 self.mode_prefs.agent_trust_mode,
2926 self.mode_prefs.agent_approval_mode,
2927 );
2928 }
2929
2930 /// Update the durable Act approval choice. Entering Full Access enables
2931 /// trust mode; leaving it removes that implicit elevation while preserving
2932 /// an independently enabled trust baseline in other posture transitions.
2933 /// Plan remains read-only.
2934 pub fn set_agent_approval_posture(&mut self, next: ApprovalMode) {
2935 let trust_mode = if next == ApprovalMode::Bypass {
2936 true
2937 } else if self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass {
2938 false
2939 } else {
2940 self.mode_prefs.agent_trust_mode
2941 };
2942 self.set_agent_runtime_baseline(self.mode_prefs.agent_allow_shell, trust_mode, next);
2943 }
2944
2945 #[must_use]
2946 pub fn approval_policy_locked(&self) -> bool {
2947 self.approval_policy_locked
2948 }
2949
2950 #[cfg(test)]
2951 #[must_use]
2952 pub fn approval_policy_requirements_managed(&self) -> bool {
2953 self.approval_policy_requirements_managed
2954 }
2955
2956 /// Session transitions must never detach live runtime producers. Late
2957 /// engine, compaction, purge, or background-task events could otherwise
2958 /// contaminate the replacement session after clear/load/new.
2959 #[must_use]
2960 pub fn session_transition_blocked(&self) -> bool {
2961 self.is_loading
2962 || self.runtime_turn_status.as_deref() == Some("in_progress")
2963 || self.is_compacting
2964 || self.is_purging
2965 || self
2966 .task_panel
2967 .iter()
2968 .any(|task| matches!(task.status.as_str(), "queued" | "running"))
2969 }
2970
2971 /// Whether the interface is asking the user to make a decision. Ambient
2972 /// motion yields across the whole frame while this is true; freezing one
2973 /// task marker still leaves distracting movement in peripheral vision.
2974 #[must_use]
2975 pub fn attention_hold_active(&self) -> bool {
2976 !self.view_stack.is_empty()
2977 || self.pending_user_input_prompt.is_some()
2978 || self
2979 .task_panel
2980 .iter()
2981 .any(|task| matches!(task.status.as_str(), "waiting" | "needs_user"))
2982 }
2983
2984 pub fn mark_approval_policy_locked(&mut self) {
2985 self.approval_policy_locked = true;
2986 self.approval_policy_root_editable = true;
2987 }
2988
2989 pub fn clear_saved_approval_policy_lock(&mut self) {
2990 if !self.approval_policy_requirements_managed {
2991 self.approval_policy_locked = false;
2992 self.approval_policy_root_editable = false;
2993 }
2994 }
2995
2996 /// Execute hooks for a specific event with the given context
2997 pub fn execute_hooks(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
2998 self.hooks.execute(event, context)
2999 }
3000
3001 /// Submit observer hooks off the terminal event loop. Foreground in hook
3002 /// configuration still means ordered/awaited within the worker; it no
3003 /// longer means the UI waits on the child process.
3004 pub fn submit_hooks(&self, event: HookEvent, context: HookContext) -> Result<(), String> {
3005 self.hooks.submit_observer(event, context)
3006 }
3007
3008 /// Preserve a lost observer event independently of the ordinary status
3009 /// line. Agent lifecycle handlers immediately replace `status_message`
3010 /// with their normal progress text, so a submission failure belongs in
3011 /// the toast queue instead of that transient slot.
3012 pub fn surface_observer_hook_submission_failure(&mut self, error: String) {
3013 tracing::warn!(target: "hooks", %error, "observer hook was not submitted");
3014 self.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
3015 self.needs_redraw = true;
3016 }
3017
3018 /// Create a hook context with common fields pre-populated
3019 pub fn base_hook_context(&self) -> HookContext {
3020 HookContext::new()
3021 .with_mode(self.mode.label())
3022 .with_workspace(self.workspace.clone())
3023 .with_model(&self.model)
3024 .with_session_id(self.hooks.session_id())
3025 .with_tokens(self.session.total_tokens)
3026 }
3027
3028 /// Soft cap on [`Self::history`] length. When history exceeds this count,
3029 /// the oldest cells are folded into a single placeholder to bound memory
3030 /// and render cost (#399 S2). The cap is generous — 5000 cells is more
3031 /// than enough to keep the visible transcript intact across sessions.
3032 pub const HISTORY_SOFT_CAP: usize = 5_000;
3033
3034 /// Number of oldest cells to fold when the soft cap fires. Folding in
3035 /// batches amortizes the cost instead of triggering on every push.
3036 const HISTORY_FOLD_BATCH: usize = 1_000;
3037
3038 pub fn add_message(&mut self, msg: HistoryCell) {
3039 let rev = self.fresh_history_revision();
3040 self.history.push(msg);
3041 self.history_revisions.push(rev);
3042 self.history_version = self.history_version.wrapping_add(1);
3043
3044 // Bound history length: when the soft cap fires, fold the oldest
3045 // batch into a single ArchivedContext placeholder.
3046 self.maybe_fold_history();
3047 let selection_has_range = self
3048 .viewport
3049 .transcript_selection
3050 .ordered_endpoints()
3051 .is_some_and(|(start, end)| start != end);
3052 if self.viewport.transcript_scroll.is_at_tail()
3053 && !self.viewport.transcript_selection.dragging
3054 && !selection_has_range
3055 && !self.user_scrolled_during_stream
3056 {
3057 self.scroll_to_bottom();
3058 }
3059 }
3060
3061 /// Add `delta` to the parent-turn session cost and bump the displayed
3062 /// high-water mark so the footer total never reverses (#244).
3063 #[allow(dead_code)]
3064 pub fn accrue_session_cost(&mut self, delta: f64) {
3065 self.accrue_session_cost_estimate(CostEstimate::usd_only(delta));
3066 }
3067
3068 /// Record what a turn's pricing attempt actually produced.
3069 ///
3070 /// Called with the same audit that feeds [`Self::accrue_session_cost_estimate`],
3071 /// so the completeness counters can never drift from the running total.
3072 /// Routes that do not meter money at all (OAuth, token plans, local models)
3073 /// are not counted in either bucket — there is no dollar figure to be
3074 /// incomplete about.
3075 pub fn record_turn_cost_audit(&mut self, audit: &crate::pricing::TurnCostAudit) {
3076 // Provenance is recorded for every audited turn, priced or not: knowing
3077 // *which* row a total was built from is part of explaining the total.
3078 if let Some(provenance) = audit.provenance.as_ref() {
3079 self.session
3080 .cost_pricing_provenances
3081 .insert(provenance.label().to_string());
3082 }
3083 if let Some(defect) = audit.live_pricing_defect.as_ref() {
3084 if audit.estimate.is_some() {
3085 self.session
3086 .cost_live_pricing_defects
3087 .insert(defect.label().to_string());
3088 } else {
3089 self.session
3090 .cost_live_pricing_unusable_defects
3091 .insert(defect.label().to_string());
3092 }
3093 }
3094 // An exactly non-metered route has no dollar figure to be incomplete
3095 // about, so it joins neither coverage bucket. Everything else does,
3096 // including a route whose billing basis could not be established.
3097 if !audit.counts_toward_money_coverage() {
3098 return;
3099 }
3100 for class in &audit.unpriced_classes {
3101 self.session
3102 .cost_unpriced_classes
3103 .insert(class.label().to_string());
3104 }
3105 if !audit.usd_priced
3106 && let Some(reason) = audit.unpriced_reason
3107 {
3108 self.session
3109 .cost_unpriced_reasons
3110 .insert(reason.label().to_string());
3111 }
3112 if !audit.cny_priced {
3113 self.session.cost_cny_unpriced_reasons.insert(
3114 audit
3115 .unpriced_reason
3116 .map_or("currency_not_published", |reason| reason.label())
3117 .to_string(),
3118 );
3119 }
3120 if audit.usd_priced {
3121 self.session.cost_priced_turns = self.session.cost_priced_turns.saturating_add(1);
3122 } else {
3123 self.session.cost_unpriced_turns = self.session.cost_unpriced_turns.saturating_add(1);
3124 }
3125 if audit.cny_priced {
3126 self.session.cost_cny_priced_turns =
3127 self.session.cost_cny_priced_turns.saturating_add(1);
3128 } else {
3129 self.session.cost_cny_unpriced_turns =
3130 self.session.cost_cny_unpriced_turns.saturating_add(1);
3131 }
3132 }
3133
3134 /// Record the route a turn's cost was resolved against, redacted.
3135 pub fn record_turn_cost_route_receipt(&mut self, receipt: String) {
3136 // Bound the set so a session that rotates routes cannot grow it without
3137 // limit; the first 32 distinct routes are more than enough to explain a
3138 // total, and the cap is reported rather than silently truncating.
3139 const MAX_ROUTE_RECEIPTS: usize = 32;
3140 if self.session.cost_route_receipts.len() < MAX_ROUTE_RECEIPTS {
3141 self.session.cost_route_receipts.insert(receipt);
3142 } else {
3143 self.session
3144 .cost_route_receipts
3145 .insert("…additional routes not recorded (receipt cap reached)".to_string());
3146 }
3147 }
3148
3149 /// Fold a drained background-cost pool's coverage into the session's.
3150 ///
3151 /// The caller has already added `pool.estimate` to the running total; this
3152 /// adds the counters and provenance that qualify it, from the same drained
3153 /// value, so the two can never disagree.
3154 pub fn absorb_background_cost_coverage(
3155 &mut self,
3156 pool: &crate::cost_status::PendingBackgroundCost,
3157 ) {
3158 self.session.cost_priced_turns = self
3159 .session
3160 .cost_priced_turns
3161 .saturating_add(pool.priced_turns);
3162 self.session.cost_unpriced_turns = self
3163 .session
3164 .cost_unpriced_turns
3165 .saturating_add(pool.unpriced_turns);
3166 self.session.cost_cny_priced_turns = self
3167 .session
3168 .cost_cny_priced_turns
3169 .saturating_add(pool.cny_priced_turns);
3170 self.session.cost_cny_unpriced_turns = self
3171 .session
3172 .cost_cny_unpriced_turns
3173 .saturating_add(pool.cny_unpriced_turns);
3174 for reason in &pool.unpriced_reasons {
3175 self.session
3176 .cost_unpriced_reasons
3177 .insert((*reason).to_string());
3178 }
3179 for reason in &pool.cny_unpriced_reasons {
3180 self.session
3181 .cost_cny_unpriced_reasons
3182 .insert((*reason).to_string());
3183 }
3184 for class in &pool.unpriced_classes {
3185 self.session
3186 .cost_unpriced_classes
3187 .insert((*class).to_string());
3188 }
3189 for provenance in &pool.pricing_provenances {
3190 self.session
3191 .cost_pricing_provenances
3192 .insert((*provenance).to_string());
3193 }
3194 for defect in &pool.live_pricing_defects {
3195 self.session
3196 .cost_live_pricing_defects
3197 .insert((*defect).to_string());
3198 }
3199 for defect in &pool.live_pricing_unusable_defects {
3200 self.session
3201 .cost_live_pricing_unusable_defects
3202 .insert((*defect).to_string());
3203 }
3204 for receipt in &pool.route_receipts {
3205 self.record_turn_cost_route_receipt(receipt.clone());
3206 }
3207 }
3208
3209 /// Clear every live cost-coverage counter.
3210 ///
3211 /// Used by `/new` and by the session-load path: loading a session must not
3212 /// leave the previous session's priced/unpriced turns attached to a total
3213 /// that no longer contains them (#4318).
3214 pub fn reset_cost_coverage(&mut self) {
3215 self.session.cost_priced_turns = 0;
3216 self.session.cost_unpriced_turns = 0;
3217 self.session.cost_cny_priced_turns = 0;
3218 self.session.cost_cny_unpriced_turns = 0;
3219 self.session.cost_unpriced_reasons.clear();
3220 self.session.cost_cny_unpriced_reasons.clear();
3221 self.session.cost_unpriced_classes.clear();
3222 self.session.cost_pricing_provenances.clear();
3223 self.session.cost_live_pricing_defects.clear();
3224 self.session.cost_live_pricing_unusable_defects.clear();
3225 self.session.cost_route_receipts.clear();
3226 self.session.cost_coverage_unknown_legacy = false;
3227 }
3228
3229 /// Add a dual-currency parent-turn cost estimate.
3230 pub fn accrue_session_cost_estimate(&mut self, estimate: CostEstimate) {
3231 let total = CostEstimate {
3232 usd: self.session.session_cost,
3233 cny: self.session.session_cost_cny,
3234 }
3235 .saturating_add(estimate);
3236 self.session.session_cost = total.usd;
3237 self.session.session_cost_cny = total.cny;
3238 self.refresh_displayed_cost_high_water();
3239 }
3240
3241 /// Add `delta` to the running sub-agent cost and bump the displayed
3242 /// high-water mark so the footer total never reverses (#244).
3243 #[allow(dead_code)]
3244 pub fn accrue_subagent_cost(&mut self, delta: f64) {
3245 self.accrue_subagent_cost_estimate(CostEstimate::usd_only(delta));
3246 }
3247
3248 /// Add a dual-currency sub-agent/background cost estimate.
3249 pub fn accrue_subagent_cost_estimate(&mut self, estimate: CostEstimate) {
3250 let total = CostEstimate {
3251 usd: self.session.subagent_cost,
3252 cny: self.session.subagent_cost_cny,
3253 }
3254 .saturating_add(estimate);
3255 self.session.subagent_cost = total.usd;
3256 self.session.subagent_cost_cny = total.cny;
3257 self.refresh_displayed_cost_high_water();
3258 }
3259
3260 /// Copy current session/subagent cost accumulators into session metadata
3261 /// for persistence.
3262 pub fn sync_cost_to_metadata(&self, metadata: &mut crate::session_manager::SessionMetadata) {
3263 metadata.cost.session_cost_usd = self.session.session_cost;
3264 metadata.cost.session_cost_cny = self.session.session_cost_cny;
3265 metadata.cost.subagent_cost_usd = self.session.subagent_cost;
3266 metadata.cost.subagent_cost_cny = self.session.subagent_cost_cny;
3267 metadata.cost.displayed_cost_high_water_usd = self.session.displayed_cost_high_water;
3268 metadata.cost.displayed_cost_high_water_cny = self.session.displayed_cost_high_water_cny;
3269 // Coverage travels with the money it qualifies. A restored total without
3270 // these fields cannot say what it covers, and its serde defaults read as
3271 // a *complete* total covering zero turns — so they are persisted together
3272 // and `coverage_recorded` marks that this writer actually knew (#4318).
3273 metadata.cost.priced_turns = self.session.cost_priced_turns;
3274 metadata.cost.unpriced_turns = self.session.cost_unpriced_turns;
3275 metadata.cost.cny_priced_turns = self.session.cost_cny_priced_turns;
3276 metadata.cost.cny_unpriced_turns = self.session.cost_cny_unpriced_turns;
3277 metadata.cost.unpriced_reasons = self.session.cost_unpriced_reasons.clone();
3278 metadata.cost.cny_unpriced_reasons = self.session.cost_cny_unpriced_reasons.clone();
3279 metadata.cost.unpriced_classes = self.session.cost_unpriced_classes.clone();
3280 metadata.cost.pricing_provenances = self.session.cost_pricing_provenances.clone();
3281 metadata.cost.live_pricing_defects = self.session.cost_live_pricing_defects.clone();
3282 metadata.cost.live_pricing_unusable_defects =
3283 self.session.cost_live_pricing_unusable_defects.clone();
3284 metadata.cost.route_receipts = self.session.cost_route_receipts.clone();
3285 // A session restored as legacy-unknown stays unknown when re-saved:
3286 // re-writing it as "recorded" would launder the missing evidence into an
3287 // apparently complete zero.
3288 metadata.cost.coverage_recorded = !self.session.cost_coverage_unknown_legacy;
3289 // Persist cumulative turn duration so the footer "worked" chip
3290 // survives session save/restore (#2038).
3291 metadata.cumulative_turn_secs = self.cumulative_turn_duration.as_secs();
3292 }
3293
3294 /// Recompute the displayed cost high-water mark. Called any time a cost
3295 /// counter is mutated; never decreases.
3296 pub fn refresh_displayed_cost_high_water(&mut self) {
3297 let current = CostEstimate {
3298 usd: self.session.session_cost,
3299 cny: self.session.session_cost_cny,
3300 }
3301 .saturating_add(CostEstimate {
3302 usd: self.session.subagent_cost,
3303 cny: self.session.subagent_cost_cny,
3304 });
3305 if current.usd > self.session.displayed_cost_high_water {
3306 self.session.displayed_cost_high_water = current.usd;
3307 }
3308 if current.cny > self.session.displayed_cost_high_water_cny {
3309 self.session.displayed_cost_high_water_cny = current.cny;
3310 }
3311 }
3312
3313 /// Read the visible session+sub-agent cost. Guaranteed monotonic across
3314 /// reconciliation events (cache adjustments, provisional → final swaps)
3315 /// for the lifetime of one session (#244).
3316 #[allow(dead_code)]
3317 pub fn displayed_session_cost(&self) -> f64 {
3318 self.displayed_session_cost_for_currency(CostCurrency::Usd)
3319 }
3320
3321 /// Read the visible session+sub-agent cost in the chosen currency.
3322 pub fn displayed_session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
3323 match self.cost_display_currency(currency) {
3324 CostCurrency::Usd => {
3325 let current = CostEstimate {
3326 usd: self.session.session_cost,
3327 cny: 0.0,
3328 }
3329 .saturating_add(CostEstimate {
3330 usd: self.session.subagent_cost,
3331 cny: 0.0,
3332 })
3333 .usd;
3334 current.max(self.session.displayed_cost_high_water)
3335 }
3336 CostCurrency::Cny => {
3337 let current = CostEstimate {
3338 usd: 0.0,
3339 cny: self.session.session_cost_cny,
3340 }
3341 .saturating_add(CostEstimate {
3342 usd: 0.0,
3343 cny: self.session.subagent_cost_cny,
3344 })
3345 .cny;
3346 current.max(self.session.displayed_cost_high_water_cny)
3347 }
3348 }
3349 }
3350
3351 pub fn session_cost_for_currency(&self, currency: CostCurrency) -> f64 {
3352 match self.cost_display_currency(currency) {
3353 CostCurrency::Usd => self.session.session_cost,
3354 CostCurrency::Cny => self.session.session_cost_cny,
3355 }
3356 }
3357
3358 pub fn subagent_cost_for_currency(&self, currency: CostCurrency) -> f64 {
3359 match self.cost_display_currency(currency) {
3360 CostCurrency::Usd => self.session.subagent_cost,
3361 CostCurrency::Cny => self.session.subagent_cost_cny,
3362 }
3363 }
3364
3365 pub fn format_cost_amount(&self, amount: f64) -> String {
3366 crate::pricing::format_cost_amount(amount, self.cost_display_currency(self.cost_currency))
3367 }
3368
3369 pub fn format_cost_amount_precise(&self, amount: f64) -> String {
3370 crate::pricing::format_cost_amount_precise(
3371 amount,
3372 self.cost_display_currency(self.cost_currency),
3373 )
3374 }
3375
3376 pub(crate) fn cost_display_currency(&self, currency: CostCurrency) -> CostCurrency {
3377 if currency == CostCurrency::Cny
3378 && self.session.cost_cny_priced_turns == 0
3379 && self.session.cost_priced_turns > 0
3380 {
3381 CostCurrency::Usd
3382 } else {
3383 currency
3384 }
3385 }
3386
3387 /// Fold the oldest [`Self::HISTORY_FOLD_BATCH`] cells into a single
3388 /// `ArchivedContext` placeholder when history exceeds the soft cap.
3389 /// Called from [`Self::add_message`]; the caller is responsible for
3390 /// also removing the folded range from any auxiliary per-cell maps.
3391 fn maybe_fold_history(&mut self) {
3392 if self.history.len() <= Self::HISTORY_SOFT_CAP {
3393 return;
3394 }
3395
3396 let fold_count = Self::HISTORY_FOLD_BATCH.min(self.history.len());
3397 // Don't fold into the very last cell(s) — keep a buffer of
3398 // non-folded cells so the visible transcript tail stays intact.
3399 let keep_tail = Self::HISTORY_SOFT_CAP.saturating_sub(Self::HISTORY_FOLD_BATCH);
3400 if self.history.len().saturating_sub(fold_count) < keep_tail {
3401 return;
3402 }
3403
3404 // Gather the range of cell indices we are folding.
3405 let folded: Vec<HistoryCell> = self.history.drain(..fold_count).collect();
3406 let folded_revs: Vec<u64> = self.history_revisions.drain(..fold_count).collect();
3407 let _ = folded_revs; // revisions are discarded with the cells
3408
3409 // Shift all per-cell index maps down by `fold_count`.
3410 self.shift_history_maps_down(fold_count);
3411
3412 // Build a single placeholder cell summarizing the folded range.
3413 let total_folded = folded.len();
3414 let summary = format!(
3415 "{total_folded} older transcript cells folded to bound memory. \
3416 Use /sessions to load a prior session snapshot if needed."
3417 );
3418 let placeholder = HistoryCell::ArchivedContext {
3419 level: 0,
3420 range: format!("cells 0-{}", total_folded.saturating_sub(1)),
3421 tokens: String::new(),
3422 density: String::new(),
3423 model: String::new(),
3424 timestamp: String::new(),
3425 summary,
3426 };
3427
3428 // Insert the placeholder at the front.
3429 let rev = self.fresh_history_revision();
3430 self.history.insert(0, placeholder);
3431 self.history_revisions.insert(0, rev);
3432 self.history_version = self.history_version.wrapping_add(1);
3433 self.needs_redraw = true;
3434 }
3435
3436 /// Shift all per-cell index maps down by `n` after removing the first
3437 /// `n` history cells. Every map key >= n is mapped to key - n; keys < n
3438 /// are dropped.
3439 fn shift_history_maps_down(&mut self, n: usize) {
3440 // tool_cells: HashMap<String, usize>
3441 self.tool_cells.retain(|_, idx| {
3442 if *idx >= n {
3443 *idx -= n;
3444 true
3445 } else {
3446 false
3447 }
3448 });
3449
3450 // tool_details_by_cell: HashMap<usize, ToolDetailRecord>
3451 self.tool_details_by_cell = std::mem::take(&mut self.tool_details_by_cell)
3452 .into_iter()
3453 .filter_map(|(idx, detail)| {
3454 if idx >= n {
3455 Some((idx - n, detail))
3456 } else {
3457 None
3458 }
3459 })
3460 .collect();
3461
3462 // context_references_by_cell
3463 self.context_references_by_cell = std::mem::take(&mut self.context_references_by_cell)
3464 .into_iter()
3465 .filter_map(|(idx, refs)| {
3466 if idx >= n {
3467 Some((idx - n, refs))
3468 } else {
3469 None
3470 }
3471 })
3472 .collect();
3473 self.rebuild_session_context_references();
3474
3475 // subagent_card_index
3476 self.subagent_card_index.retain(|_, idx| {
3477 if *idx >= n {
3478 *idx -= n;
3479 true
3480 } else {
3481 false
3482 }
3483 });
3484
3485 // last_fanout_card_index
3486 if let Some(ref mut idx) = self.last_fanout_card_index {
3487 if *idx >= n {
3488 *idx -= n;
3489 } else {
3490 self.last_fanout_card_index = None;
3491 }
3492 }
3493
3494 // collapsed_cells
3495 self.collapsed_cells = std::mem::take(&mut self.collapsed_cells)
3496 .into_iter()
3497 .filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
3498 .collect();
3499 self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs)
3500 .into_iter()
3501 .filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
3502 .collect();
3503 self.collapsed_cell_map.clear();
3504 }
3505
3506 /// #3030: return the stable user-facing label for an agent id
3507 /// ("Agent 3"), assigning the next sequential label on first sight.
3508 pub(crate) fn ensure_agent_label(&mut self, agent_id: &str) -> String {
3509 if let Some(label) = self.agent_label_map.get(agent_id) {
3510 return label.clone();
3511 }
3512 self.agent_counter = self.agent_counter.saturating_add(1);
3513 let label = format!("Agent {}", self.agent_counter);
3514 self.agent_label_map
3515 .insert(agent_id.to_string(), label.clone());
3516 label
3517 }
3518
3519 /// #3030: read-only label lookup with raw-id fallback for agents the
3520 /// label map has never seen.
3521 pub(crate) fn agent_display_label(&self, agent_id: &str) -> String {
3522 self.agent_label_map
3523 .get(agent_id)
3524 .cloned()
3525 .unwrap_or_else(|| agent_id.to_string())
3526 }
3527
3528 pub fn mark_history_updated(&mut self) {
3529 self.history_version = self.history_version.wrapping_add(1);
3530 // Resync per-cell revisions to history.len(). This is the
3531 // "I-don't-know-which-cell-changed" path: if cells were appended in
3532 // bulk (e.g. session resume, compaction), every new cell gets a
3533 // fresh revision; if cells were removed, drop trailing revs. We
3534 // intentionally do NOT bump revisions for indices that already had
3535 // one — the cache will reuse those. Callers that mutate a specific
3536 // cell's content must call `bump_history_cell(idx)` instead.
3537 self.resync_history_revisions();
3538 self.needs_redraw = true;
3539 }
3540
3541 /// Invalidate only transcript rows whose visible liveness marker is
3542 /// time-based. Animation redraws must not churn settled history, but they
3543 /// do need fresh cache keys for running history and active-cell entries.
3544 pub(crate) fn mark_live_motion_updated(&mut self) {
3545 self.mark_live_motion_updated_inner(true);
3546 }
3547
3548 /// Invalidate only committed live rows. The translation placeholder path
3549 /// already bumps the whole active-cell cache when it changes, so the UI
3550 /// uses this narrower path to avoid bumping that revision twice.
3551 pub(crate) fn mark_live_history_motion_updated(&mut self) {
3552 self.mark_live_motion_updated_inner(false);
3553 }
3554
3555 fn mark_live_motion_updated_inner(&mut self, invalidate_active_cell: bool) {
3556 self.resync_history_revisions();
3557 let live_history_indices: Vec<usize> = self
3558 .history
3559 .iter()
3560 .enumerate()
3561 .filter_map(|(index, cell)| cell.has_live_motion().then_some(index))
3562 .collect();
3563 for index in live_history_indices {
3564 let previous_revision = self.history_revisions.get(index).copied();
3565 let streaming_content_len = (self.streaming_message_index == Some(index))
3566 .then(|| match self.history.get(index) {
3567 Some(HistoryCell::Assistant {
3568 content,
3569 streaming: true,
3570 }) => Some(content.len()),
3571 _ => None,
3572 })
3573 .flatten();
3574 let revision = self.fresh_history_revision();
3575 if let Some(slot) = self.history_revisions.get_mut(index) {
3576 *slot = revision;
3577 }
3578 if let (Some(previous_revision), Some(content_len)) =
3579 (previous_revision, streaming_content_len)
3580 {
3581 let from_revision = self
3582 .streaming_source_receipt
3583 .filter(|receipt| {
3584 receipt.cell_index == index && receipt.to_revision == previous_revision
3585 })
3586 .map_or(previous_revision, |receipt| receipt.from_revision);
3587 self.streaming_source_receipt =
3588 Some(crate::tui::transcript::StreamingSourceReceipt {
3589 cell_index: index,
3590 from_revision,
3591 to_revision: revision,
3592 content_len,
3593 });
3594 }
3595 }
3596
3597 let active_has_live_motion = self
3598 .active_cell
3599 .as_ref()
3600 .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion));
3601 if invalidate_active_cell && active_has_live_motion {
3602 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
3603 if let Some(active) = self.active_cell.as_mut() {
3604 active.bump_revision();
3605 }
3606 }
3607
3608 self.history_version = self.history_version.wrapping_add(1);
3609 self.needs_redraw = true;
3610 }
3611
3612 /// Issue a fresh, monotonically increasing revision counter for a new
3613 /// history cell. Wrapping is acceptable — collisions are astronomically
3614 /// rare and at worst trigger one extra re-render.
3615 fn fresh_history_revision(&mut self) -> u64 {
3616 let rev = self.next_history_revision;
3617 self.next_history_revision = self.next_history_revision.wrapping_add(1);
3618 rev
3619 }
3620
3621 /// Bring `history_revisions` back into shape (`history_revisions.len() ==
3622 /// history.len()`). Pushes fresh revs for newly appended cells, truncates
3623 /// for cells that were removed. **Does not** invalidate existing entries.
3624 pub fn resync_history_revisions(&mut self) {
3625 if self.history_revisions.len() < self.history.len() {
3626 let needed = self.history.len() - self.history_revisions.len();
3627 for _ in 0..needed {
3628 let rev = self.fresh_history_revision();
3629 self.history_revisions.push(rev);
3630 }
3631 } else if self.history_revisions.len() > self.history.len() {
3632 self.history_revisions.truncate(self.history.len());
3633 }
3634 }
3635
3636 /// Bump the revision counter of a single history cell so the transcript
3637 /// cache re-renders it on the next frame. Use this whenever a cell's
3638 /// content (e.g. a streaming Assistant body) is mutated in place.
3639 pub fn bump_history_cell(&mut self, idx: usize) {
3640 // Resync first in case callers mutated `history` directly without
3641 // pushing through `add_message`. After resync, the index is valid
3642 // (or out of bounds — in which case there's nothing to bump).
3643 self.resync_history_revisions();
3644 if self
3645 .streaming_source_receipt
3646 .is_some_and(|receipt| receipt.cell_index == idx)
3647 {
3648 self.streaming_source_receipt = None;
3649 }
3650 if let Some(rev) = self.history_revisions.get_mut(idx) {
3651 let new_rev = self.next_history_revision;
3652 self.next_history_revision = self.next_history_revision.wrapping_add(1);
3653 *rev = new_rev;
3654 }
3655 self.history_version = self.history_version.wrapping_add(1);
3656 self.needs_redraw = true;
3657 }
3658
3659 /// Append a single history cell, allocating a fresh per-cell revision.
3660 /// Equivalent to `add_message` but exposed as a generic alias so call
3661 /// sites currently doing `app.history.push(...)` followed by
3662 /// `app.mark_history_updated()` can collapse to one helper.
3663 pub fn push_history_cell(&mut self, cell: HistoryCell) {
3664 let rev = self.fresh_history_revision();
3665 self.history.push(cell);
3666 self.history_revisions.push(rev);
3667 self.history_version = self.history_version.wrapping_add(1);
3668 self.maybe_fold_history();
3669 self.needs_redraw = true;
3670 }
3671
3672 /// Append a batch of history cells, allocating fresh revisions.
3673 pub fn extend_history<I>(&mut self, cells: I)
3674 where
3675 I: IntoIterator<Item = HistoryCell>,
3676 {
3677 for cell in cells {
3678 let rev = self.fresh_history_revision();
3679 self.history.push(cell);
3680 self.history_revisions.push(rev);
3681 }
3682 self.maybe_fold_history();
3683 self.history_version = self.history_version.wrapping_add(1);
3684 self.needs_redraw = true;
3685 }
3686
3687 /// Clear the history and its session-scoped side indexes. Used by /clear,
3688 /// session reset, and other "wipe and reload" flows.
3689 pub fn clear_history(&mut self) {
3690 self.history.clear();
3691 self.history_revisions.clear();
3692 self.context_references_by_cell.clear();
3693 self.session_context_references.clear();
3694 self.session_artifacts.clear();
3695 self.collapsed_cells.clear();
3696 self.expanded_tool_runs.clear();
3697 self.collapsed_cell_map.clear();
3698 self.history_version = self.history_version.wrapping_add(1);
3699 self.needs_redraw = true;
3700 }
3701
3702 /// Pop the trailing history cell, keeping revisions in sync.
3703 pub fn pop_history(&mut self) -> Option<HistoryCell> {
3704 let cell = self.history.pop();
3705 if cell.is_some() {
3706 self.history_revisions.pop();
3707 self.context_references_by_cell.remove(&self.history.len());
3708 self.rebuild_session_context_references();
3709 self.expanded_tool_runs
3710 .retain(|idx| *idx < self.history.len());
3711 self.history_version = self.history_version.wrapping_add(1);
3712 self.needs_redraw = true;
3713 }
3714 cell
3715 }
3716
3717 /// Truncate `history` (and the parallel `history_revisions` + auxiliary
3718 /// per-cell maps) so that only cells with index `< new_len` remain.
3719 /// Used by Esc-Esc backtrack (#133) to roll the visible transcript
3720 /// back to a chosen user message. Cells dropped here are gone — the
3721 /// caller is expected to also trim the matching `api_messages` so the
3722 /// next turn matches what the user sees.
3723 pub fn truncate_history_to(&mut self, new_len: usize) {
3724 if new_len >= self.history.len() {
3725 return;
3726 }
3727 self.history.truncate(new_len);
3728 if self.history_revisions.len() > new_len {
3729 self.history_revisions.truncate(new_len);
3730 }
3731 // Drop any auxiliary maps keyed on history indices that now point
3732 // past the new tail. We keep the rest intact so unaffected tool
3733 // cells continue to render correctly.
3734 self.tool_cells.retain(|_, idx| *idx < new_len);
3735 self.tool_details_by_cell.retain(|idx, _| *idx < new_len);
3736 self.context_references_by_cell
3737 .retain(|idx, _| *idx < new_len);
3738 self.rebuild_session_context_references();
3739 self.subagent_card_index.retain(|_, idx| *idx < new_len);
3740 if self
3741 .last_fanout_card_index
3742 .is_some_and(|idx| idx >= new_len)
3743 {
3744 self.last_fanout_card_index = None;
3745 }
3746 // Drop collapsed cells that reference indices past the new tail.
3747 self.collapsed_cells.retain(|idx| *idx < new_len);
3748 self.expanded_tool_runs.retain(|idx| *idx < new_len);
3749 self.collapsed_cell_map.clear();
3750 self.history_version = self.history_version.wrapping_add(1);
3751 self.needs_redraw = true;
3752 }
3753
3754 #[must_use]
3755 pub fn tool_collapse_active(&self) -> bool {
3756 self.tool_collapse_threshold > 0 && self.tool_collapse_mode.is_active(self.calm_mode)
3757 }
3758
3759 #[must_use]
3760 pub fn tool_run_start_for_history_index(&self, index: usize) -> Option<usize> {
3761 if !self.tool_collapse_active() {
3762 return None;
3763 }
3764 let active_entries = self
3765 .active_cell
3766 .as_ref()
3767 .map_or(&[][..], crate::tui::active_cell::ActiveCell::entries);
3768 if index >= self.history.len().saturating_add(active_entries.len()) {
3769 return None;
3770 }
3771 crate::tui::history::detect_tool_runs_from_slices(
3772 &self.history,
3773 active_entries,
3774 self.tool_collapse_threshold,
3775 )
3776 .into_iter()
3777 .find(|run| index >= run.start && index < run.start.saturating_add(run.count))
3778 .map(|run| run.start)
3779 }
3780
3781 pub fn toggle_tool_run_expansion_at(&mut self, index: usize) -> bool {
3782 let Some(start) = self.tool_run_start_for_history_index(index) else {
3783 return false;
3784 };
3785 if self.expanded_tool_runs.remove(&start) {
3786 self.status_message = Some("Tool group collapsed".to_string());
3787 } else {
3788 self.expanded_tool_runs.insert(start);
3789 self.status_message = Some("Tool group expanded".to_string());
3790 }
3791 self.mark_history_updated();
3792 true
3793 }
3794
3795 /// Bump the active-cell revision counter and request a redraw.
3796 ///
3797 /// Use this whenever an entry inside `active_cell` is mutated. The
3798 /// transcript cache combines this counter with `history_version` to
3799 /// produce a per-cell revision so the synthetic active-cell row can be
3800 /// re-rendered without invalidating committed history cells.
3801 pub fn bump_active_cell_revision(&mut self) {
3802 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
3803 if let Some(active) = self.active_cell.as_mut() {
3804 active.bump_revision();
3805 }
3806 self.history_version = self.history_version.wrapping_add(1);
3807 self.needs_redraw = true;
3808 }
3809
3810 /// Total number of cells in the *virtual* transcript: `history.len()`
3811 /// plus active cell entries (if any).
3812 #[must_use]
3813 #[allow(dead_code)] // Reserved for renderers that need a unified cell count.
3814 pub fn virtual_cell_count(&self) -> usize {
3815 self.history.len() + self.active_cell.as_ref().map_or(0, ActiveCell::entry_count)
3816 }
3817
3818 #[must_use]
3819 pub fn original_cell_index_for_rendered(&self, rendered_index: usize) -> usize {
3820 self.collapsed_cell_map
3821 .get(rendered_index)
3822 .copied()
3823 .unwrap_or(rendered_index)
3824 }
3825
3826 /// Resolve a virtual cell index to either a committed history cell or an
3827 /// active-cell entry. Used by the pager / details lookup code so it can
3828 /// transparently address still-in-flight cells.
3829 #[must_use]
3830 #[allow(dead_code)] // Used by the upcoming pager rewrite (read-only resolver).
3831 pub fn cell_at_virtual_index(&self, index: usize) -> Option<&HistoryCell> {
3832 if index < self.history.len() {
3833 self.history.get(index)
3834 } else {
3835 let entry_idx = index - self.history.len();
3836 self.active_cell
3837 .as_ref()
3838 .and_then(|active| active.entries().get(entry_idx))
3839 }
3840 }
3841
3842 /// Resolve the tool-detail record for a committed or still-active virtual
3843 /// transcript cell.
3844 #[must_use]
3845 pub fn tool_detail_record_for_cell(&self, index: usize) -> Option<&ToolDetailRecord> {
3846 if let Some(detail) = self.tool_details_by_cell.get(&index) {
3847 return Some(detail);
3848 }
3849 self.active_tool_details
3850 .values()
3851 .find(|detail| self.tool_cells.get(&detail.tool_id).copied() == Some(index))
3852 }
3853
3854 /// Whether a virtual transcript cell can open a meaningful `v` detail
3855 /// view. Thinking cells render their own raw text inline so there is no
3856 /// separate "raw" target — only tool / sub-agent cells get the hint.
3857 #[must_use]
3858 pub fn cell_has_detail_target(&self, index: usize) -> bool {
3859 self.tool_detail_record_for_cell(index).is_some()
3860 || matches!(
3861 self.cell_at_virtual_index(index),
3862 Some(HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
3863 )
3864 }
3865
3866 /// Pick the detail target for the current viewport. This is used by the
3867 /// transcript highlight and footer hint so they agree with `v`.
3868 #[must_use]
3869 pub fn detail_cell_index_for_viewport(
3870 &self,
3871 top: usize,
3872 visible: usize,
3873 line_meta: &[TranscriptLineMeta],
3874 ) -> Option<usize> {
3875 let selected_cell = self
3876 .viewport
3877 .transcript_selection
3878 .ordered_endpoints()
3879 .and_then(|(start, _)| line_meta.get(start.line_index))
3880 .and_then(TranscriptLineMeta::cell_line)
3881 .map(|(cell_index, _)| self.original_cell_index_for_rendered(cell_index))
3882 .filter(|&idx| self.cell_has_detail_target(idx));
3883 if selected_cell.is_some() {
3884 return selected_cell;
3885 }
3886
3887 let start = top.min(line_meta.len().saturating_sub(1));
3888 let end = start.saturating_add(visible).min(line_meta.len());
3889 for meta in line_meta.iter().take(end).skip(start) {
3890 let Some((cell_index, _)) = meta.cell_line() else {
3891 continue;
3892 };
3893 let cell_index = self.original_cell_index_for_rendered(cell_index);
3894 if self.cell_has_detail_target(cell_index) {
3895 return Some(cell_index);
3896 }
3897 }
3898
3899 (0..self.virtual_cell_count())
3900 .rev()
3901 .find(|&idx| self.cell_has_detail_target(idx))
3902 }
3903
3904 pub fn record_context_references(
3905 &mut self,
3906 history_cell: usize,
3907 message_index: usize,
3908 references: Vec<ContextReference>,
3909 ) {
3910 if references.is_empty() {
3911 return;
3912 }
3913 let records: Vec<SessionContextReference> = references
3914 .into_iter()
3915 .map(|reference| SessionContextReference {
3916 message_index,
3917 reference,
3918 })
3919 .collect();
3920 self.context_references_by_cell
3921 .insert(history_cell, records.clone());
3922 self.rebuild_session_context_references();
3923 self.needs_redraw = true;
3924 }
3925
3926 pub fn sync_context_references_from_session(
3927 &mut self,
3928 references: &[SessionContextReference],
3929 message_to_cell: &HashMap<usize, usize>,
3930 ) {
3931 self.context_references_by_cell.clear();
3932 for record in references {
3933 let Some(&cell_index) = message_to_cell.get(&record.message_index) else {
3934 continue;
3935 };
3936 self.context_references_by_cell
3937 .entry(cell_index)
3938 .or_default()
3939 .push(record.clone());
3940 }
3941 self.rebuild_session_context_references();
3942 }
3943
3944 fn rebuild_session_context_references(&mut self) {
3945 let mut records: Vec<SessionContextReference> = self
3946 .context_references_by_cell
3947 .values()
3948 .flat_map(|records| records.iter().cloned())
3949 .collect();
3950 records.sort_by_key(|record| record.message_index);
3951 self.session_context_references = records;
3952 }
3953
3954 /// Mutable variant of [`Self::cell_at_virtual_index`]. Bumps the
3955 /// appropriate revision counter (active-cell revision when targeting an
3956 /// in-flight entry, history version otherwise).
3957 pub fn cell_at_virtual_index_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
3958 if index < self.history.len() {
3959 // Bump only the targeted cell's revision; leave every other
3960 // cell's cached render intact.
3961 self.resync_history_revisions();
3962 if let Some(rev) = self.history_revisions.get_mut(index) {
3963 let new_rev = self.next_history_revision;
3964 self.next_history_revision = self.next_history_revision.wrapping_add(1);
3965 *rev = new_rev;
3966 }
3967 self.history_version = self.history_version.wrapping_add(1);
3968 self.history.get_mut(index)
3969 } else {
3970 let entry_idx = index - self.history.len();
3971 self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
3972 self.history_version = self.history_version.wrapping_add(1);
3973 self.active_cell
3974 .as_mut()
3975 .and_then(|active| active.entry_mut(entry_idx))
3976 }
3977 }
3978
3979 /// Drain the active cell into history. Companion maps that reference
3980 /// active-cell entries by virtual index (`tool_cells`,
3981 /// `tool_details_by_cell`) are rewritten to point at the new history
3982 /// indices. Idempotent — calling this when there is no active cell is a
3983 /// no-op.
3984 ///
3985 /// Caller is responsible for first marking in-progress entries with the
3986 /// terminal status they want (e.g. via
3987 /// [`ActiveCell::mark_in_progress_as_interrupted`]).
3988 pub fn flush_active_cell(&mut self) {
3989 let Some(mut active) = self.active_cell.take() else {
3990 self.streaming_thinking_active_entry = None;
3991 return;
3992 };
3993 if active.is_empty() {
3994 self.exploring_cell = None;
3995 self.exploring_entries.clear();
3996 self.active_tool_details.clear();
3997 self.active_tool_entry_completed_at.clear();
3998 self.streaming_thinking_active_entry = None;
3999 self.bump_active_cell_revision();
4000 return;
4001 }
4002
4003 if let Some(entry_idx) = self.streaming_thinking_active_entry.take()
4004 && let Some(HistoryCell::Thinking { streaming, .. }) = active.entry_mut(entry_idx)
4005 {
4006 *streaming = false;
4007 }
4008
4009 let base_index = self.history.len();
4010 // Completed tools are removed from `tool_cells` before the active
4011 // group flushes, but `ActiveCell` deliberately keeps the stable
4012 // tool-to-entry binding until drain. Capture that binding first so
4013 // sequential or parallel tools in one model turn retain distinct raw
4014 // detail records instead of all falling back to the first cell.
4015 let detail_cell_indices: HashMap<String, usize> = self
4016 .active_tool_details
4017 .keys()
4018 .filter_map(|tool_id| {
4019 active
4020 .entry_index_for_tool(tool_id)
4021 .map(|entry_idx| (tool_id.clone(), base_index + entry_idx))
4022 })
4023 .collect();
4024 let drained = active.drain();
4025
4026 let mut details = std::mem::take(&mut self.active_tool_details);
4027 self.active_tool_entry_completed_at.clear();
4028 for (tool_id, detail) in details.drain() {
4029 let cell_index = detail_cell_indices
4030 .get(&tool_id)
4031 .copied()
4032 .or_else(|| self.tool_cells.get(&tool_id).copied())
4033 .unwrap_or(base_index);
4034 self.tool_details_by_cell
4035 .entry(cell_index)
4036 .or_insert(detail);
4037 }
4038
4039 self.exploring_cell = None;
4040 self.exploring_entries.clear();
4041
4042 for cell in drained {
4043 let rev = self.fresh_history_revision();
4044 self.history.push(cell);
4045 self.history_revisions.push(rev);
4046 }
4047 self.history_version = self.history_version.wrapping_add(1);
4048 self.needs_redraw = true;
4049 let selection_has_range = self
4050 .viewport
4051 .transcript_selection
4052 .ordered_endpoints()
4053 .is_some_and(|(start, end)| start != end);
4054 if self.viewport.transcript_scroll.is_at_tail()
4055 && !self.viewport.transcript_selection.dragging
4056 && !selection_has_range
4057 && !self.user_scrolled_during_stream
4058 {
4059 self.scroll_to_bottom();
4060 }
4061 }
4062
4063 /// Mark every still-running entry in the active cell as interrupted, then
4064 /// flush. Convenience helper for cancellation paths.
4065 pub fn finalize_active_cell_as_interrupted(&mut self) {
4066 if let Some(active) = self.active_cell.as_mut() {
4067 active.mark_in_progress_as_interrupted();
4068 }
4069 self.flush_active_cell();
4070 // #4121: interrupt finalizes running workflow children as cancelled
4071 // and preserves the completed panel until the next run starts.
4072 if let Some(panel) = self.workflow_panel.as_mut() {
4073 panel.finalize_interrupt();
4074 self.needs_redraw = true;
4075 }
4076 }
4077
4078 /// Apply a workflow panel event, creating the panel on first `RunStarted`.
4079 ///
4080 /// Returns whether this event should request an immediate repaint.
4081 /// Budget-only updates always mutate panel state but leave repaint to the
4082 /// caller so high-frequency fan-out budget ticks can be paced (#4095).
4083 pub fn apply_workflow_panel_event(
4084 &mut self,
4085 event: crate::tui::widgets::workflow_panel::WorkflowPanelEvent,
4086 ) -> bool {
4087 use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelEvent};
4088 let budget_only = matches!(event, WorkflowPanelEvent::BudgetUpdated { .. });
4089 match (&mut self.workflow_panel, &event) {
4090 (
4091 None,
4092 WorkflowPanelEvent::RunStarted {
4093 run_id,
4094 workflow_goal,
4095 workflow_id,
4096 token_budget,
4097 at_ms,
4098 ..
4099 },
4100 ) => {
4101 let label = workflow_goal
4102 .clone()
4103 .or_else(|| workflow_id.clone())
4104 .unwrap_or_else(|| "workflow".to_string());
4105 let mut panel = WorkflowPanel::new(run_id.clone(), label, *at_ms);
4106 panel.locale = self.ui_locale;
4107 panel.budget_total = *token_budget;
4108 panel.budget_remaining = *token_budget;
4109 self.workflow_panel = Some(panel);
4110 }
4111 (None, _) => {
4112 // No panel yet and event is not a start — seed a shell panel
4113 // so late events still surface rather than being dropped.
4114 let mut panel = WorkflowPanel::new("workflow", "workflow", 0);
4115 panel.locale = self.ui_locale;
4116 panel.apply_event(event);
4117 self.workflow_panel = Some(panel);
4118 }
4119 (Some(panel), _) => {
4120 panel.apply_event(event);
4121 }
4122 }
4123 if !budget_only {
4124 self.needs_redraw = true;
4125 }
4126 !budget_only
4127 }
4128
4129 /// Toggle the workflow panel expand/collapse state. Returns true when a
4130 /// panel was present and toggled.
4131 pub fn toggle_workflow_panel(&mut self) -> bool {
4132 let Some(panel) = self.workflow_panel.as_mut() else {
4133 return false;
4134 };
4135 let _ = panel.toggle_expanded();
4136 self.needs_redraw = true;
4137 true
4138 }
4139
4140 /// How long the "press Ctrl+C again to quit" prompt stays armed before it
4141 /// silently expires.
4142 pub const QUIT_CONFIRMATION_WINDOW: Duration = Duration::from_secs(2);
4143
4144 /// Arm the quit confirmation timer. The next Ctrl+C within
4145 /// [`Self::QUIT_CONFIRMATION_WINDOW`] should exit the app cleanly. Call this only
4146 /// from idle state — while a turn is in flight or a modal is open Ctrl+C
4147 /// retains its existing "interrupt this turn" / "close modal" semantics.
4148 pub fn arm_quit(&mut self) {
4149 self.quit_armed_until = Some(Instant::now() + Self::QUIT_CONFIRMATION_WINDOW);
4150 self.needs_redraw = true;
4151 }
4152
4153 /// Whether the quit timer is currently armed (i.e. a prior Ctrl+C set it
4154 /// and it hasn't expired yet).
4155 pub fn quit_is_armed(&self) -> bool {
4156 self.quit_armed_until
4157 .map(|deadline| Instant::now() < deadline)
4158 .unwrap_or(false)
4159 }
4160
4161 /// Clear the quit-armed timer. Call when expiry is detected on a tick or
4162 /// when the user takes any other action that should disarm the prompt
4163 /// (typing, sending a message, etc.).
4164 pub fn disarm_quit(&mut self) {
4165 if self.quit_armed_until.is_some() {
4166 self.quit_armed_until = None;
4167 self.needs_redraw = true;
4168 }
4169 }
4170
4171 /// Tick called from the redraw loop. Lets time-based UI state (the
4172 /// quit-armed prompt) expire even when no input event is delivered.
4173 pub fn tick_quit_armed(&mut self) {
4174 if let Some(deadline) = self.quit_armed_until
4175 && Instant::now() >= deadline
4176 {
4177 self.quit_armed_until = None;
4178 self.needs_redraw = true;
4179 }
4180 }
4181
4182 pub const RECEIPT_VISIBLE_DURATION: Duration = Duration::from_secs(8);
4183
4184 pub fn set_receipt_text(&mut self, text: impl Into<String>) {
4185 self.receipt_text = Some(text.into());
4186 self.receipt_started_at = Some(Instant::now());
4187 self.needs_redraw = true;
4188 }
4189
4190 pub fn clear_receipt(&mut self) {
4191 if self.receipt_text.is_some() || self.receipt_started_at.is_some() {
4192 self.receipt_text = None;
4193 self.receipt_started_at = None;
4194 self.needs_redraw = true;
4195 }
4196 }
4197
4198 /// Tick called from the redraw loop so transient receipts leave the UI
4199 /// without waiting for the next keypress.
4200 pub fn tick_receipt(&mut self) {
4201 if self
4202 .receipt_started_at
4203 .is_some_and(|started| started.elapsed() > Self::RECEIPT_VISIBLE_DURATION)
4204 {
4205 self.clear_receipt();
4206 }
4207 }
4208
4209 pub fn close_slash_menu(&mut self) {
4210 self.slash_menu_hidden = true;
4211 self.needs_redraw = true;
4212 }
4213
4214 /// Ceiling on how far the ambient clock advances per sampled frame.
4215 /// Bursty draw schedules (fast token streams) slow the aquarium down
4216 /// instead of teleporting creatures across the gap.
4217 pub const AMBIENT_MAX_STEP_MS: u128 = 160;
4218 /// Gentle-motion grace before a fully idle aquarium settles still.
4219 pub const AMBIENT_IDLE_SETTLE_MS: u64 = 6_000;
4220
4221 /// Advance and read the ambient animation clock. Every decorative
4222 /// position derives from this value; it moves by real elapsed time
4223 /// clamped to [`Self::AMBIENT_MAX_STEP_MS`] per sample, so motion stays
4224 /// continuous no matter how irregular the draw schedule is.
4225 pub fn sample_ambient_clock_ms(&mut self) -> u128 {
4226 let now = Instant::now();
4227 let step = self
4228 .ambient_clock_sampled_at
4229 .map(|last| {
4230 now.duration_since(last)
4231 .as_millis()
4232 .min(Self::AMBIENT_MAX_STEP_MS)
4233 })
4234 .unwrap_or(0);
4235 self.ambient_clock_sampled_at = Some(now);
4236 self.ambient_clock_ms = self.ambient_clock_ms.saturating_add(step);
4237 self.ambient_clock_ms
4238 }
4239
4240 /// Track idleness and report whether the ambient scene has settled.
4241 /// `busy` is the caller's aggregation of live activity signals (running
4242 /// turn, live sub-agents, active durable tasks, completion exhale, user
4243 /// browsing). While busy the idle anchor clears; once quiet, motion gets
4244 /// [`Self::AMBIENT_IDLE_SETTLE_MS`] of grace and then stills.
4245 pub fn ambient_idle_settled(&mut self, busy: bool, now: Instant) -> bool {
4246 if busy {
4247 self.ambient_idle_since = None;
4248 return false;
4249 }
4250 let since = *self.ambient_idle_since.get_or_insert(now);
4251 now.duration_since(since) >= Duration::from_millis(Self::AMBIENT_IDLE_SETTLE_MS)
4252 }
4253
4254 /// Resolve one motion policy for every surface that can request or paint
4255 /// animation. `fancy_animations = false` is a true still mode even when
4256 /// the separate accessibility preference is left at its default.
4257 #[must_use]
4258 pub(crate) fn motion_policy(&self) -> MotionPolicy {
4259 MotionPolicy::from_settings(
4260 self.low_motion,
4261 self.fancy_animations,
4262 self.constrained_frame_rate,
4263 )
4264 }
4265
4266 /// Bridge the centralized policy into transcript renderers that still
4267 /// accept the legacy boolean motion contract.
4268 #[must_use]
4269 pub(crate) fn effective_low_motion_for_status(&self) -> bool {
4270 self.motion_policy().as_low_motion()
4271 }
4272
4273 pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
4274 TranscriptRenderOptions {
4275 show_thinking: self.show_thinking,
4276 thinking_highlight: self.thinking_highlight,
4277 thinking_default_expanded: self.thinking_default_expanded,
4278 verbose: self.verbose_transcript,
4279 show_tool_details: self.show_tool_details,
4280 inline_diff_mode: self.inline_diff_mode,
4281 calm_mode: self.calm_mode,
4282 low_motion: self.effective_low_motion_for_status(),
4283 motion_mode: self.motion_policy().mode(),
4284 spacing: self.transcript_spacing,
4285 palette_mode: self.ui_theme.mode,
4286 }
4287 }
4288
4289 /// Handle terminal resize event.
4290 pub fn handle_resize(&mut self, _width: u16, _height: u16) {
4291 let preserved_scroll = (!self.viewport.transcript_scroll.is_at_tail())
4292 .then_some(self.viewport.last_transcript_top);
4293 self.viewport.transcript_cache = TranscriptViewCache::new();
4294
4295 if let Some(top) = preserved_scroll {
4296 self.viewport.transcript_scroll = TranscriptScroll::at_line(top);
4297 }
4298
4299 self.viewport.pending_scroll_delta = 0;
4300 self.viewport.transcript_selection.clear();
4301
4302 self.viewport.last_transcript_area = None;
4303 self.viewport.last_approval_area = None;
4304 self.viewport.last_transcript_top = 0;
4305 // Seed visible height from the resize event so paging keys use a
4306 // useful page size immediately, before the next render updates it.
4307 self.viewport.last_transcript_visible = (_height as usize).saturating_sub(2).max(1);
4308 self.viewport.last_transcript_total = 0;
4309 self.viewport.last_transcript_padding_top = 0;
4310 self.viewport.jump_to_latest_button_area = None;
4311
4312 self.mark_history_updated();
4313 }
4314
4315 pub fn scroll_up(&mut self, amount: usize) {
4316 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
4317 self.viewport.pending_scroll_delta =
4318 self.viewport.pending_scroll_delta.saturating_sub(delta);
4319 self.user_scrolled_during_stream = true;
4320 self.needs_redraw = true;
4321 }
4322
4323 pub fn scroll_down(&mut self, amount: usize) {
4324 let delta = i32::try_from(amount).unwrap_or(i32::MAX);
4325 self.viewport.pending_scroll_delta =
4326 self.viewport.pending_scroll_delta.saturating_add(delta);
4327 self.user_scrolled_during_stream = true;
4328 self.needs_redraw = true;
4329 }
4330
4331 pub fn scroll_to_bottom(&mut self) {
4332 self.viewport.transcript_scroll = TranscriptScroll::to_bottom();
4333 self.viewport.pending_scroll_delta = 0;
4334 self.viewport.jump_to_latest_button_area = None;
4335 self.user_scrolled_during_stream = false;
4336 self.needs_redraw = true;
4337 }
4338
4339 pub fn queue_message(&mut self, message: QueuedMessage) {
4340 self.queued_messages.push_back(message);
4341 }
4342
4343 pub fn pop_queued_message(&mut self) -> Option<QueuedMessage> {
4344 self.queued_messages.pop_front()
4345 }
4346
4347 pub fn remove_queued_message(&mut self, index: usize) -> Option<QueuedMessage> {
4348 self.queued_messages.remove(index)
4349 }
4350
4351 pub fn queued_message_count(&self) -> usize {
4352 self.queued_messages.len()
4353 }
4354
4355 /// Pop the most-recently queued message back into the composer for editing
4356 /// (issue #85 — ↑ affordance). The popped message is parked in
4357 /// [`Self::queued_draft`] so the next Enter re-queues it carrying its
4358 /// original skill instruction. No-op if the composer already has typed
4359 /// content or a draft is already being edited — surfacing the affordance
4360 /// would be ambiguous in either case.
4361 ///
4362 /// Returns `true` when the composer state was mutated.
4363 pub fn pop_last_queued_into_draft(&mut self) -> bool {
4364 if !self.input.is_empty() || self.queued_draft.is_some() {
4365 return false;
4366 }
4367 let Some(msg) = self.queued_messages.pop_back() else {
4368 return false;
4369 };
4370 self.input = msg.display.clone();
4371 self.cursor_position = char_count(&self.input);
4372 self.selected_attachment_index = None;
4373 self.queued_draft = Some(msg);
4374 self.needs_redraw = true;
4375 true
4376 }
4377
4378 /// Stop editing a queued follow-up and put the original queued message back
4379 /// at the tail where [`Self::pop_last_queued_into_draft`] took it from.
4380 pub fn cancel_queued_draft_edit(&mut self) -> bool {
4381 let Some(draft) = self.queued_draft.take() else {
4382 return false;
4383 };
4384 self.queued_messages.push_back(draft);
4385 self.clear_input_recoverable();
4386 self.needs_redraw = true;
4387 true
4388 }
4389
4390 /// Park a legacy pending steer. New keyboard handling routes running-turn
4391 /// drafts through Ctrl+Enter (same-turn steer) or Enter (next-turn
4392 /// follow-up).
4393 #[allow(dead_code)]
4394 pub fn push_pending_steer(&mut self, message: QueuedMessage) {
4395 self.pending_steers.push_back(message);
4396 self.submit_pending_steers_after_interrupt = true;
4397 self.needs_redraw = true;
4398 }
4399
4400 /// Drain the pending-steer queue and clear the resend flag. Returns the
4401 /// messages in submit order (oldest first).
4402 pub fn drain_pending_steers(&mut self) -> Vec<QueuedMessage> {
4403 self.submit_pending_steers_after_interrupt = false;
4404 if self.pending_steers.is_empty() {
4405 return Vec::new();
4406 }
4407 self.needs_redraw = true;
4408 self.pending_steers.drain(..).collect()
4409 }
4410
4411 /// Decide how to route a fresh non-empty composer submit.
4412 ///
4413 /// Running turns always queue bare-Enter submissions. Ctrl+Enter is the
4414 /// single explicit gesture for amending the active turn, regardless of
4415 /// whether the provider has emitted its first token yet.
4416 ///
4417 /// Truth table:
4418 /// offline=F, busy=F → Immediate
4419 /// offline=F, busy=T, streaming=* → Queue (Ctrl+Enter steers)
4420 /// offline=T, busy=* → Queue
4421 #[must_use]
4422 pub fn decide_submit_disposition(&self) -> SubmitDisposition {
4423 if self.offline_mode {
4424 return SubmitDisposition::Queue;
4425 }
4426 // A spawned dispatch is still resolving route/sending the op (#4605);
4427 // queue rather than spawn a second dispatch that could reorder ops.
4428 if self.dispatch_in_flight {
4429 return SubmitDisposition::Queue;
4430 }
4431 if !self.is_loading {
4432 return SubmitDisposition::Immediate;
4433 }
4434 // Busy: queue the message. Steer is an explicit Ctrl+Enter gesture,
4435 // not a timing-sensitive change in bare Enter behavior.
4436 SubmitDisposition::Queue
4437 }
4438
4439 /// Resolve Enter-shaped input from the same state used by composer hints.
4440 ///
4441 /// Bare Enter is portable across supported terminals: it sends while idle,
4442 /// queues while busy, and an empty Enter promotes the oldest queued message
4443 /// into the active turn. Ctrl+Enter remains accepted when a terminal can
4444 /// report it distinctly, but is intentionally not advertised because many
4445 /// terminals encode it exactly like Enter.
4446 #[must_use]
4447 pub fn decide_composer_submit(&self, chord: ComposerSubmitChord) -> ComposerSubmitAction {
4448 if self.input.is_empty() {
4449 if self.is_loading && self.queued_draft.is_none() && !self.queued_messages.is_empty() {
4450 return ComposerSubmitAction::SendQueuedNow;
4451 }
4452 return ComposerSubmitAction::Noop;
4453 }
4454
4455 let disposition = match chord {
4456 ComposerSubmitChord::Enter => self.decide_submit_disposition(),
4457 ComposerSubmitChord::CtrlEnter
4458 if self.is_loading && !self.offline_mode && !self.dispatch_in_flight =>
4459 {
4460 SubmitDisposition::Steer
4461 }
4462 ComposerSubmitChord::CtrlEnter => self.decide_submit_disposition(),
4463 };
4464 ComposerSubmitAction::Submit(disposition)
4465 }
4466
4467 /// Resolve what bare Enter should do right now.
4468 ///
4469 /// Kept for compatibility with older call sites and tests.
4470 #[must_use]
4471 #[allow(dead_code)]
4472 pub fn enter_with_double_tap(&mut self) -> Option<SubmitDisposition> {
4473 // Name kept for call-site stability; the double-tap window is gone.
4474 Some(self.decide_submit_disposition())
4475 }
4476
4477 /// Mark the in-flight streaming Assistant cell as interrupted: prepend
4478 /// `[interrupted]` to whatever streamed so far (so the user can see what
4479 /// was salvaged) and flip `streaming` off so the spinner halts. No-op if
4480 /// no Assistant cell is currently streaming.
4481 ///
4482 /// Deliberate divergence from openai/codex which discards partial output
4483 /// on abort — V4 thinking is expensive and the user usually wants to see
4484 /// what the model produced before steering.
4485 pub fn finalize_streaming_assistant_as_interrupted(&mut self) {
4486 let Some(index) = self.streaming_message_index.take() else {
4487 return;
4488 };
4489 if let Some(HistoryCell::Assistant { content, streaming }) = self.history.get_mut(index) {
4490 *streaming = false;
4491 if content.is_empty() {
4492 *content = "[interrupted]".to_string();
4493 } else if !content.starts_with("[interrupted]") {
4494 content.insert_str(0, "[interrupted] ");
4495 }
4496 }
4497 self.bump_history_cell(index);
4498 }
4499
4500 /// Retry a `try_lock` up to `retries` times with a 1ms pause between
4501 /// attempts. Returns `Some(guard)` on success, `None` if the lock
4502 /// remains contended after all retries.
4503 fn retry_lock<T>(
4504 mutex: &tokio::sync::Mutex<T>,
4505 retries: u32,
4506 ) -> Option<tokio::sync::MutexGuard<'_, T>> {
4507 for _ in 0..retries {
4508 if let Ok(guard) = mutex.try_lock() {
4509 return Some(guard);
4510 }
4511 std::thread::sleep(std::time::Duration::from_millis(1));
4512 }
4513 None
4514 }
4515
4516 /// Capture the durable Work state without ever converting lock contention
4517 /// into an empty snapshot.
4518 pub fn work_state_snapshot(&self) -> Result<Option<SessionWorkState>, String> {
4519 if let Some(work) = self.runtime_services.work.as_ref() {
4520 return work
4521 .capture(self.current_session_id.as_deref())
4522 .map(|state| {
4523 state.map(|state| SessionWorkState {
4524 graph: Some(state.graph),
4525 todos: state.todos,
4526 plan: state.plan,
4527 })
4528 });
4529 }
4530 let todos = Self::retry_lock(&self.todos, 100)
4531 .ok_or_else(|| "To-do state is busy; try saving again".to_string())?;
4532 let plan = Self::retry_lock(&self.plan_state, 100)
4533 .ok_or_else(|| "Plan state is busy; try saving again".to_string())?;
4534 let state = SessionWorkState {
4535 graph: None,
4536 todos: todos.snapshot(),
4537 plan: plan.snapshot(),
4538 };
4539 Ok((!state.is_empty()).then_some(state))
4540 }
4541
4542 /// Non-blocking snapshot for the render/event loop. Automatic persistence
4543 /// must skip a contended first save instead of pausing the UI or writing a
4544 /// false empty state.
4545 pub fn try_work_state_snapshot(&mut self) -> Result<Option<SessionWorkState>, String> {
4546 if let Some(work) = self.runtime_services.work.as_ref() {
4547 let state = work
4548 .try_capture(self.current_session_id.as_deref())
4549 .map(|state| {
4550 state.map(|state| SessionWorkState {
4551 graph: Some(state.graph),
4552 todos: state.todos,
4553 plan: state.plan,
4554 })
4555 })?;
4556 self.last_known_work_state = Some(state.clone());
4557 return Ok(state);
4558 }
4559 let todos = self
4560 .todos
4561 .try_lock()
4562 .map_err(|_| "To-do state is busy".to_string())?;
4563 let plan = self
4564 .plan_state
4565 .try_lock()
4566 .map_err(|_| "Plan state is busy".to_string())?;
4567 let state = SessionWorkState {
4568 graph: None,
4569 todos: todos.snapshot(),
4570 plan: plan.snapshot(),
4571 };
4572 let state = (!state.is_empty()).then_some(state);
4573 drop(plan);
4574 drop(todos);
4575 self.last_known_work_state = Some(state.clone());
4576 Ok(state)
4577 }
4578
4579 /// Atomically replace the live Work state from a saved session.
4580 pub fn restore_work_state(
4581 &mut self,
4582 session_id: &str,
4583 workspace: &Path,
4584 state: Option<&SessionWorkState>,
4585 ) -> Result<(), String> {
4586 if let Some(work) = self.runtime_services.work.as_ref() {
4587 let empty = SessionWorkState::default();
4588 let state = state.unwrap_or(&empty);
4589 work.restore_with_workspace_owner_bindings(
4590 session_id,
4591 workspace,
4592 state.graph.as_ref(),
4593 &state.todos,
4594 &state.plan,
4595 )?;
4596 let restored = work.capture(Some(session_id))?;
4597 let normalized_state = restored.map(|state| SessionWorkState {
4598 graph: Some(state.graph),
4599 todos: state.todos,
4600 plan: state.plan,
4601 });
4602 self.work_surface.record_restored_session(
4603 session_id,
4604 normalized_state
4605 .as_ref()
4606 .and_then(|state| state.graph.as_ref()),
4607 );
4608 self.cached_work_summary = None;
4609 self.last_known_work_state = Some(normalized_state);
4610 return Ok(());
4611 }
4612 let (restored_todos, restored_plan) = match state {
4613 Some(state) => (
4614 TodoList::from_snapshot(&state.todos)?,
4615 PlanState::from_snapshot(&state.plan),
4616 ),
4617 None => (TodoList::new(), PlanState::default()),
4618 };
4619 let normalized_state = SessionWorkState {
4620 graph: None,
4621 todos: restored_todos.snapshot(),
4622 plan: restored_plan.snapshot(),
4623 };
4624
4625 let mut todos = Self::retry_lock(&self.todos, 100)
4626 .ok_or_else(|| "To-do state is busy; session was not restored".to_string())?;
4627 let mut plan = Self::retry_lock(&self.plan_state, 100)
4628 .ok_or_else(|| "Plan state is busy; session was not restored".to_string())?;
4629 *todos = restored_todos;
4630 *plan = restored_plan;
4631 drop(plan);
4632 drop(todos);
4633 self.work_surface.record_restored_session(session_id, None);
4634 self.cached_work_summary = None;
4635 self.last_known_work_state =
4636 Some((!normalized_state.is_empty()).then_some(normalized_state));
4637 Ok(())
4638 }
4639
4640 pub fn clear_todos(&mut self) -> bool {
4641 if let Some(work) = self.runtime_services.work.as_ref() {
4642 if !work.clear(self.current_session_id.as_deref()) {
4643 return false;
4644 }
4645 self.cached_work_summary = None;
4646 self.last_known_work_state = Some(None);
4647 return true;
4648 }
4649 // Acquire both stores before mutating either one. `/clear` must never
4650 // report success after clearing only half of the Work surface.
4651 let Some(mut todos) = Self::retry_lock(&self.todos, 100) else {
4652 return false;
4653 };
4654 let Some(mut plan) = Self::retry_lock(&self.plan_state, 100) else {
4655 return false;
4656 };
4657 todos.clear();
4658 *plan = PlanState::default();
4659 drop(plan);
4660 drop(todos);
4661 self.cached_work_summary = None;
4662 self.last_known_work_state = Some(None);
4663 true
4664 }
4665
4666 /// Publish a validated Work Graph transaction after a synchronous caller
4667 /// has completed its atomic session write.
4668 pub fn publish_pending_work_state(&mut self) -> Result<bool, String> {
4669 let published = self
4670 .runtime_services
4671 .work
4672 .as_ref()
4673 .map_or(Ok(false), |work| work.publish_pending_sync())?;
4674 if published {
4675 self.cached_work_summary = None;
4676 }
4677 Ok(published)
4678 }
4679
4680 pub fn update_model_compaction_budget(&mut self) {
4681 let model = self.effective_model_for_budget().to_string();
4682 self.compact_threshold = crate::route_budget::compaction_threshold_for_route_at_percent(
4683 self.api_provider,
4684 &model,
4685 self.active_route_limits,
4686 self.auto_compact_threshold_percent,
4687 );
4688 if !self.auto_compact_user_configured {
4689 self.auto_compact = crate::route_budget::auto_compact_default_for_route(
4690 self.api_provider,
4691 &model,
4692 self.active_route_limits,
4693 );
4694 }
4695 }
4696
4697 pub fn set_active_route_limits(&mut self, limits: RouteLimits) {
4698 self.active_route_limits = crate::route_budget::known_route_limits(limits);
4699 }
4700
4701 /// Install an already-resolved runtime route receipt in one operation so
4702 /// endpoint-sensitive reasoning and context reporting cannot drift apart.
4703 pub fn set_active_route_resolution(
4704 &mut self,
4705 base_url: impl Into<String>,
4706 limits: RouteLimits,
4707 context_window_source: crate::route_runtime::ContextWindowSource,
4708 ) {
4709 self.active_route_base_url = base_url.into();
4710 self.set_active_route_limits(limits);
4711 self.active_context_window_source = context_window_source;
4712 }
4713
4714 pub fn set_active_context_window_override(&mut self, context_window: Option<u32>) {
4715 self.active_context_window_override = context_window;
4716 if context_window.is_some() {
4717 self.active_context_window_source =
4718 crate::route_runtime::ContextWindowSource::Configured;
4719 }
4720 if self.active_route_limits.is_none() {
4721 self.active_route_limits = self.context_window_override_limits();
4722 }
4723 }
4724
4725 pub fn context_window_override_limits(&self) -> Option<RouteLimits> {
4726 self.active_context_window_override
4727 .map(|window| RouteLimits {
4728 context_tokens: Some(u64::from(window)),
4729 ..RouteLimits::default()
4730 })
4731 }
4732
4733 pub fn set_model_selection(&mut self, model: String) {
4734 let auto_model = model.trim().eq_ignore_ascii_case("auto");
4735 self.model = if auto_model {
4736 "auto".to_string()
4737 } else {
4738 model
4739 };
4740 self.auto_model = auto_model;
4741 self.last_effective_model = None;
4742 self.last_effective_provider = None;
4743 self.last_effective_provider_identity = None;
4744 self.last_auto_route_receipt = None;
4745 self.pending_auto_route_receipt = None;
4746 self.last_effective_reasoning_effort = None;
4747 // Auto model routing is independent from an explicitly requested raw
4748 // reasoning tier. Never reuse the route-normalized live value here:
4749 // fixed DeepSeek can collapse low→high and Codex off→low.
4750 if auto_model {
4751 self.reasoning_effort = self
4752 .reasoning_effort_preference
4753 .unwrap_or(ReasoningEffort::Auto);
4754 } else {
4755 let requested = self
4756 .reasoning_effort_preference
4757 .unwrap_or(self.reasoning_effort);
4758 self.reasoning_effort = requested.normalize_for_provider(self.api_provider);
4759 }
4760 }
4761
4762 pub fn model_selection_for_persistence(&self) -> String {
4763 if self.auto_model || self.model.trim().eq_ignore_ascii_case("auto") {
4764 "auto".to_string()
4765 } else {
4766 self.model.clone()
4767 }
4768 }
4769
4770 /// Atomic latest Auto route metadata for session snapshots. The provider,
4771 /// exact identity, model, and receipt are either persisted together or
4772 /// omitted together so a resumed session cannot display a mixed route.
4773 #[must_use]
4774 pub(crate) fn auto_route_for_persistence(
4775 &self,
4776 ) -> Option<crate::session_manager::SavedAutoRouteReceipt> {
4777 if !self.auto_model {
4778 return None;
4779 }
4780 let (provider, model, receipt) = (
4781 self.last_effective_provider?,
4782 self.last_effective_model.as_ref()?,
4783 self.last_auto_route_receipt.as_ref()?,
4784 );
4785 if model.trim().is_empty() {
4786 return None;
4787 }
4788 let provider_identity = self
4789 .last_effective_provider_identity
4790 .clone()
4791 .unwrap_or_else(|| {
4792 if provider == ApiProvider::Custom {
4793 self.provider_identity_for_persistence().to_string()
4794 } else {
4795 provider.as_str().to_string()
4796 }
4797 });
4798 Some(crate::session_manager::SavedAutoRouteReceipt {
4799 provider,
4800 provider_identity,
4801 model: model.clone(),
4802 receipt: receipt.clone(),
4803 effective_reasoning_effort: self.last_effective_reasoning_effort.map(Into::into),
4804 })
4805 }
4806
4807 #[must_use]
4808 pub(crate) fn provider_identity_for_persistence(&self) -> &str {
4809 if self.api_provider == ApiProvider::Custom {
4810 &self.provider_identity
4811 } else {
4812 self.api_provider.as_str()
4813 }
4814 }
4815
4816 #[must_use]
4817 pub(crate) fn provider_id_for_persistence(&self) -> Option<&str> {
4818 self.provider_exact_id.as_deref()
4819 }
4820
4821 pub(crate) fn set_provider_identity(
4822 &mut self,
4823 provider: ApiProvider,
4824 identity: impl Into<String>,
4825 ) {
4826 let identity = identity.into();
4827 self.api_provider = provider;
4828 self.provider_exact_id = (!(provider == ApiProvider::Custom
4829 && identity.eq_ignore_ascii_case(ApiProvider::Custom.as_str())))
4830 .then(|| identity.clone());
4831 self.provider_identity = identity;
4832 }
4833
4834 pub(crate) fn set_provider_identity_record(
4835 &mut self,
4836 identity: crate::config::ProviderIdentity,
4837 ) {
4838 self.api_provider = identity.provider;
4839 self.provider_identity = identity.key;
4840 self.provider_exact_id = identity.exact_id;
4841 }
4842
4843 pub fn accepts_custom_model_ids(&self) -> bool {
4844 self.model_ids_passthrough
4845 || crate::config::provider_passes_model_through(self.api_provider)
4846 }
4847
4848 pub(crate) fn apply_provider_switch_reasoning_effort(
4849 &mut self,
4850 provider: ApiProvider,
4851 base_url: &str,
4852 model_override: Option<&str>,
4853 ) {
4854 let wire_model = model_override.unwrap_or(&self.model);
4855 let inferred = model_override.and_then(|model| {
4856 crate::config::legacy_deepseek_alias_effort_for_route(provider, base_url, model)
4857 });
4858 self.reasoning_effort = if let Some(requested) = self.reasoning_effort_preference {
4859 requested.normalize_for_route(provider, base_url, wire_model)
4860 } else if let Some(effort) = inferred {
4861 ReasoningEffort::from_setting(effort)
4862 .normalize_for_route(provider, base_url, wire_model)
4863 } else {
4864 self.reasoning_effort
4865 .normalize_for_route(provider, base_url, wire_model)
4866 };
4867 self.invalidate_route_receipts_for_reasoning_change();
4868 }
4869
4870 pub fn effective_model_for_budget(&self) -> &str {
4871 if self.auto_model {
4872 return self
4873 .last_effective_model
4874 .as_deref()
4875 .filter(|model| *model != "auto")
4876 .unwrap_or(DEFAULT_TEXT_MODEL);
4877 }
4878 &self.model
4879 }
4880
4881 pub fn model_display_label(&self) -> String {
4882 if self.auto_model {
4883 if let Some(effective) = self.last_effective_model.as_deref()
4884 && effective != "auto"
4885 {
4886 return format!("auto: {effective}");
4887 }
4888 return "auto".to_string();
4889 }
4890 self.model.clone()
4891 }
4892
4893 /// Provider/model identity used by the in-flight or most recent request.
4894 /// This is the display contract for auto routing and must match billing.
4895 #[must_use]
4896 pub fn effective_route_display(&self) -> (ApiProvider, String) {
4897 if let Some((provider, model, _)) = self.pending_turn_route.as_ref() {
4898 return (*provider, model.clone());
4899 }
4900 if self.auto_model
4901 && let (Some(provider), Some(model)) = (
4902 self.last_effective_provider,
4903 self.last_effective_model.as_ref(),
4904 )
4905 {
4906 return (provider, model.clone());
4907 }
4908 (self.api_provider, self.model_display_label())
4909 }
4910
4911 /// Exact non-secret route label for user-visible status surfaces.
4912 #[must_use]
4913 pub fn effective_route_identity_display(&self) -> (String, String) {
4914 let (provider, model) = self.effective_route_display();
4915 let identity = if provider == ApiProvider::Custom {
4916 if self.pending_turn_route.is_none() && self.auto_model {
4917 self.last_effective_provider_identity
4918 .as_deref()
4919 .unwrap_or_else(|| self.provider_identity_for_persistence())
4920 } else {
4921 self.provider_identity_for_persistence()
4922 }
4923 } else {
4924 provider.display_name()
4925 };
4926 (identity.to_string(), model)
4927 }
4928
4929 fn effective_reasoning_effort_for_active_route(
4930 &self,
4931 requested: ReasoningEffort,
4932 ) -> EffectiveReasoningEffort {
4933 let route_truth = self.active_reasoning_route_truth();
4934 let auto_route_has_receipt = self
4935 .active_turn
4936 .as_ref()
4937 .and_then(|turn| turn.route.as_ref())
4938 .is_some_and(|route| route.receipt.is_some());
4939 if self.auto_model
4940 && !auto_route_has_receipt
4941 && self.last_auto_route_receipt.is_some()
4942 && requested == self.reasoning_effort
4943 && let Some(effective) = self.last_effective_reasoning_effort
4944 {
4945 // Once a concrete Auto route has been accepted, its normalized
4946 // tier remains the display authority until the model or requested
4947 // effort changes. The configured classifier route is not evidence
4948 // of what the completed turn received.
4949 return effective;
4950 }
4951 if requested == self.reasoning_effort
4952 && requested == ReasoningEffort::Auto
4953 && let Some(effective) = self.last_effective_reasoning_effort
4954 {
4955 // The accepted route receipt is already the strongest available
4956 // truth. Preserve enabled-but-untiered and unavailable states
4957 // instead of forcing them through the tier-only projection.
4958 return effective;
4959 }
4960 let effective = if requested == ReasoningEffort::Auto {
4961 ReasoningEffort::Auto
4962 } else if self.auto_model && !auto_route_has_receipt {
4963 // The configured provider is only the classifier's starting
4964 // point, not the route that will receive the request.
4965 requested
4966 } else if let Some((provider, _, base_url, model)) = route_truth {
4967 requested.normalize_for_route(provider, base_url, model)
4968 } else {
4969 requested.normalize_for_route(
4970 self.api_provider,
4971 &self.active_route_base_url,
4972 &self.model,
4973 )
4974 };
4975
4976 // Prefer the immutable installed-client receipt while a turn is live.
4977 // If it is unavailable, only use the configured route when no pending
4978 // or active foreign route could make that identity stale.
4979 if let Some((provider, _, base_url, model)) = route_truth {
4980 if let Some(constrained) = crate::work_graph::constrained_effective_reasoning_for_route(
4981 requested.into(),
4982 provider,
4983 base_url,
4984 model,
4985 ) {
4986 return constrained.into();
4987 }
4988 } else if self.active_turn.as_ref().is_some_and(|turn| {
4989 turn.route.as_ref().is_some_and(|route| {
4990 matches!(
4991 route.provider,
4992 ApiProvider::Zai
4993 | ApiProvider::Minimax
4994 | ApiProvider::MinimaxAnthropic
4995 | ApiProvider::Custom
4996 ) && route.receipt.is_none()
4997 })
4998 }) || self
4999 .pending_turn_route
5000 .as_ref()
5001 .is_some_and(|(provider, _, _)| {
5002 matches!(
5003 provider,
5004 ApiProvider::Zai
5005 | ApiProvider::Minimax
5006 | ApiProvider::MinimaxAnthropic
5007 | ApiProvider::Custom
5008 )
5009 })
5010 {
5011 // A route without its immutable endpoint receipt cannot prove
5012 // first-party semantics from provider/model identity alone.
5013 return EffectiveReasoningEffort::Unavailable;
5014 }
5015 EffectiveReasoningEffort::Tier(effective)
5016 }
5017
5018 fn active_reasoning_route_truth(&self) -> Option<(ApiProvider, &str, &str, &str)> {
5019 if let Some(route) = self
5020 .active_turn
5021 .as_ref()
5022 .and_then(|turn| turn.route.as_ref())
5023 {
5024 route.receipt.as_ref().map(|receipt| {
5025 (
5026 receipt.provider(),
5027 receipt.provider_identity(),
5028 receipt.endpoint_identity(),
5029 receipt.wire_model(),
5030 )
5031 })
5032 } else if self.pending_turn_route.is_none() {
5033 Some((
5034 self.api_provider,
5035 self.provider_identity_for_persistence(),
5036 self.active_route_base_url.as_str(),
5037 self.model.as_str(),
5038 ))
5039 } else {
5040 None
5041 }
5042 }
5043
5044 fn reasoning_effort_resolution_label(
5045 requested: ReasoningEffort,
5046 effective: EffectiveReasoningEffort,
5047 provider: ApiProvider,
5048 ) -> String {
5049 match effective {
5050 EffectiveReasoningEffort::Tier(effective) => {
5051 if requested == effective {
5052 return effective.display_label_for_provider(provider).to_string();
5053 }
5054 let effective = effective.display_label_for_provider(provider);
5055 if requested == ReasoningEffort::Auto {
5056 format!("auto: {effective}")
5057 } else {
5058 format!("{}→{effective}", requested.short_label())
5059 }
5060 }
5061 EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => format!(
5062 "{}→thinking enabled; granularity unavailable",
5063 requested.short_label()
5064 ),
5065 EffectiveReasoningEffort::Unavailable => {
5066 format!("{}→effective unavailable", requested.short_label())
5067 }
5068 }
5069 }
5070
5071 pub fn reasoning_effort_display_label(&self) -> String {
5072 let requested = self.reasoning_effort;
5073 let effective = self.effective_reasoning_effort_for_active_route(requested);
5074 Self::reasoning_effort_resolution_label(requested, effective, self.api_provider)
5075 }
5076
5077 /// Return the concrete provider/model route whose current prompt may be
5078 /// inspected or replayed.
5079 ///
5080 /// For a fixed selection, the active route is authoritative. For Auto,
5081 /// `self.model` is only the selector sentinel, so the latest completed
5082 /// turn supplies provider/model/endpoint truth. A restored Auto session
5083 /// retains provider/model but not a raw endpoint; warmup may re-resolve
5084 /// that route from live config, while inspect fails honestly until a new
5085 /// turn captures the endpoint.
5086 #[must_use]
5087 pub(crate) fn cache_replay_target(&self) -> Option<CacheReplayTarget> {
5088 if !self.auto_model {
5089 let model = self.model.trim();
5090 if model.is_empty() || model.eq_ignore_ascii_case("auto") {
5091 return None;
5092 }
5093 let base_url = (!self.active_route_base_url.trim().is_empty())
5094 .then(|| self.active_route_base_url.clone());
5095 return Some(CacheReplayTarget {
5096 provider: self.api_provider,
5097 provider_identity: self.provider_identity_for_persistence().to_string(),
5098 provider_id: self.provider_id_for_persistence().map(str::to_string),
5099 model: model.to_string(),
5100 base_url,
5101 });
5102 }
5103
5104 let provider = self.last_effective_provider?;
5105 let model = self.last_effective_model.as_deref()?.trim();
5106 if model.is_empty() || model.eq_ignore_ascii_case("auto") {
5107 return None;
5108 }
5109 let provider_identity = self
5110 .last_effective_provider_identity
5111 .as_deref()
5112 .map(str::trim)
5113 .filter(|identity| !identity.is_empty())
5114 .map(str::to_string)
5115 .or_else(|| (provider != ApiProvider::Custom).then(|| provider.as_str().to_string()))?;
5116 let provider_id = if provider != ApiProvider::Custom {
5117 Some(provider.as_str().to_string())
5118 } else if !provider_identity.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) {
5119 Some(provider_identity.clone())
5120 } else if self.api_provider == ApiProvider::Custom
5121 && self
5122 .provider_identity_for_persistence()
5123 .eq_ignore_ascii_case(&provider_identity)
5124 {
5125 self.provider_id_for_persistence().map(str::to_string)
5126 } else {
5127 None
5128 };
5129
5130 let latest_matches_route = self
5131 .session
5132 .turn_cache_history
5133 .back()
5134 .is_some_and(|record| {
5135 record.auto_model
5136 && record.provider == Some(provider)
5137 && record
5138 .model
5139 .as_deref()
5140 .is_some_and(|record_model| record_model.eq_ignore_ascii_case(model))
5141 && record
5142 .provider_identity
5143 .as_deref()
5144 .map(str::trim)
5145 .filter(|identity| !identity.is_empty())
5146 .map_or(provider != ApiProvider::Custom, |identity| {
5147 identity == provider_identity
5148 })
5149 });
5150 let warmup_base_url = self
5151 .session
5152 .last_warmup_key
5153 .as_ref()
5154 .filter(|key| {
5155 key.provider == provider_identity
5156 && key.model.eq_ignore_ascii_case(model)
5157 && !key.base_url.trim().is_empty()
5158 })
5159 .map(|key| key.base_url.clone());
5160 let base_url = latest_matches_route
5161 .then(|| self.session.last_base_url.clone())
5162 .flatten()
5163 .or(warmup_base_url)
5164 .filter(|base_url| !base_url.trim().is_empty());
5165
5166 Some(CacheReplayTarget {
5167 provider,
5168 provider_identity,
5169 provider_id,
5170 model: model.to_string(),
5171 base_url,
5172 })
5173 }
5174
5175 /// Provider-facing effort used when replaying the current prompt for cache
5176 /// inspection or warmup on one exact route.
5177 #[must_use]
5178 pub(crate) fn reasoning_effort_api_value_for_replay(
5179 &self,
5180 provider: ApiProvider,
5181 base_url: &str,
5182 model: &str,
5183 ) -> Option<&'static str> {
5184 let requested = if self.reasoning_effort == ReasoningEffort::Auto {
5185 self.last_effective_reasoning_effort?
5186 .request_tier_for_replay()?
5187 } else {
5188 self.reasoning_effort
5189 };
5190 requested.api_value_for_route(provider, base_url, model)
5191 }
5192
5193 pub fn compaction_config(&self) -> CompactionConfig {
5194 let mut config = self.compaction_config_for_route(
5195 self.api_provider,
5196 self.effective_model_for_budget(),
5197 self.active_route_limits,
5198 );
5199 // These cached fields are the active-route compatibility authority and
5200 // are updated together by `update_model_compaction_budget`. Commands
5201 // and embedders may also adjust them directly between route updates.
5202 config.enabled = self.auto_compact;
5203 config.token_threshold = self.compact_threshold;
5204 config
5205 }
5206
5207 /// Build compaction policy from one already-resolved provider route.
5208 ///
5209 /// Auto routing can select a provider/model whose context limits differ
5210 /// from the route currently displayed by the app. Callers dispatching that
5211 /// turn must derive every compaction input from the selected descriptor,
5212 /// not from the previous route cached in `App`.
5213 pub(crate) fn compaction_config_for_route(
5214 &self,
5215 provider: ApiProvider,
5216 model: &str,
5217 route_limits: Option<RouteLimits>,
5218 ) -> CompactionConfig {
5219 CompactionConfig {
5220 enabled: if self.auto_compact_user_configured {
5221 self.auto_compact
5222 } else {
5223 crate::route_budget::auto_compact_default_for_route(provider, model, route_limits)
5224 },
5225 token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
5226 provider,
5227 model,
5228 route_limits,
5229 self.auto_compact_threshold_percent,
5230 ),
5231 model: model.to_string(),
5232 effective_context_window: Some(crate::route_budget::route_context_window_tokens(
5233 provider,
5234 model,
5235 route_limits,
5236 )),
5237 ..Default::default()
5238 }
5239 }
5240
5241 pub fn fallback_chain_entries(&self) -> Vec<(usize, ApiProvider, bool)> {
5242 let Some(chain) = &self.provider_chain else {
5243 return Vec::new();
5244 };
5245 let position = chain.position();
5246 chain
5247 .providers()
5248 .iter()
5249 .enumerate()
5250 .map(|(index, provider)| (index, ApiProvider::from_kind(*provider), index == position))
5251 .collect()
5252 }
5253
5254 pub fn fallback_chain_position(&self) -> Option<usize> {
5255 self.provider_chain.as_ref().map(ProviderChain::position)
5256 }
5257
5258 pub fn fallback_chain_len(&self) -> usize {
5259 self.provider_chain
5260 .as_ref()
5261 .map_or(0, |chain| chain.providers().len())
5262 }
5263
5264 /// Whether a fallback chain entry can serve a turn right now (#2574).
5265 ///
5266 /// Mirrors the provider picker's eligibility: hosted providers need a key
5267 /// (`has_api_key_for`, captured into `provider_readiness` at startup) while
5268 /// self-hosted providers (Ollama/vLLM/SGLang) are always ready. Providers
5269 /// absent from the snapshot default to ready so an unknown entry is tried
5270 /// rather than silently skipped.
5271 fn fallback_provider_is_ready(&self, provider: ApiProvider) -> bool {
5272 self.provider_readiness
5273 .iter()
5274 .find_map(|(candidate, ready)| (*candidate == provider).then_some(*ready))
5275 .unwrap_or(true)
5276 }
5277
5278 /// Advance to the next *eligible* provider in the fallback chain (#2574).
5279 ///
5280 /// Walks the chain from the current position, skipping entries that are not
5281 /// ready (hosted providers missing auth) and recording a clear note for each
5282 /// skip. Local providers are always eligible. Returns the first ready
5283 /// provider, or `None` (with an exhaustion reason) when every remaining entry
5284 /// is unready or the end of the chain is reached. `ProviderChain::advance`
5285 /// stays pure — the readiness filtering lives here at the App level.
5286 ///
5287 /// Note: auth-rejection (401) failures never reach this path; the caller
5288 /// excludes them from fallback so a bad key does not silently rotate
5289 /// providers (see `apply_engine_error_to_app`).
5290 ///
5291 /// Local/private policy (#2574): when the chain's primary provider is a
5292 /// self-hosted / local runtime, cloud candidates are skipped with a clear
5293 /// note so a local/private route never silently falls back out to a hosted
5294 /// provider. Self-hosted siblings remain eligible. The policy is anchored
5295 /// to the original primary; a cloud primary may still hop through a local
5296 /// runtime and then back to another cloud fallback.
5297 pub fn advance_fallback(&mut self, reason: impl Into<String>) -> Option<ApiProvider> {
5298 let reason = reason.into();
5299 self.provider_chain.as_ref()?;
5300
5301 let origin_is_local = self
5302 .provider_chain
5303 .as_ref()
5304 .and_then(|chain| chain.providers().first().copied())
5305 .map(ApiProvider::from_kind)
5306 .is_some_and(ApiProvider::is_self_hosted);
5307
5308 let mut skip_notes: Vec<String> = Vec::new();
5309 let mut chosen: Option<ApiProvider> = None;
5310 while let Some(next_kind) = self
5311 .provider_chain
5312 .as_mut()
5313 .and_then(ProviderChain::advance)
5314 {
5315 let candidate = ApiProvider::from_kind(next_kind);
5316 if origin_is_local && !candidate.is_self_hosted() {
5317 skip_notes.push(format!(
5318 "skipped {}: local/private policy (no local->cloud fallback)",
5319 candidate.as_str()
5320 ));
5321 continue;
5322 }
5323 if self.fallback_provider_is_ready(candidate) {
5324 chosen = Some(candidate);
5325 break;
5326 }
5327 skip_notes.push(format!("skipped {}: needs auth", candidate.as_str()));
5328 }
5329
5330 let skipped = if skip_notes.is_empty() {
5331 String::new()
5332 } else {
5333 format!(" ({})", skip_notes.join("; "))
5334 };
5335
5336 let Some(next_provider) = chosen else {
5337 let total = self
5338 .provider_chain
5339 .as_ref()
5340 .map_or(0, |chain| chain.providers().len());
5341 self.last_fallback_reason = Some(format!(
5342 "Fallback chain exhausted after {total} provider(s): {reason}{skipped}"
5343 ));
5344 return None;
5345 };
5346
5347 self.set_provider_identity(next_provider, next_provider.as_str());
5348 self.last_fallback_reason = Some(format!(
5349 "Fell back to {} after recoverable provider error: {reason}{skipped}",
5350 next_provider.as_str()
5351 ));
5352 Some(next_provider)
5353 }
5354
5355 pub fn is_fallback_active(&self) -> bool {
5356 self.provider_chain
5357 .as_ref()
5358 .is_some_and(ProviderChain::is_fallback_active)
5359 }
5360 }
5361
5362 pub fn media_attachment_reference(kind: &str, path: &Path, description: Option<&str>) -> String {
5363 match description {
5364 Some(description) if !description.trim().is_empty() => {
5365 format!(
5366 "[Attached {kind}: {} at {}]",
5367 description.trim(),
5368 path.display()
5369 )
5370 }
5371 _ => format!("[Attached {kind}: {}]", path.display()),
5372 }
5373 }
5374
5375 #[cfg(test)]
5376 mod tests;
5377
5377 lines RUST