Skip to main content

thndrs_lib/cli/
app.rs

1//! Application state, message types, and event dispatch (The Elm architecture/TEA).
2//!
3//! `App` holds the mutable session and prompt state. `Msg` represents input,
4//! provider, tool, permission, and lifecycle events. [`update`] applies one
5//! message and may return another message for the caller to process.
6//!
7//! The root module declares the shared state and message vocabulary. The child
8//! modules implement the event families that [`update`] dispatches:
9//!
10//! - `onboarding` handles provider setup, credential recovery, and OAuth.
11//! - `input` handles editing, history, pickers, and prompt submission.
12//! - `commands` handles slash-command parsing and command actions.
13//! - `context` handles context inspection and compaction operations.
14//! - `agent_lifecycle` handles agent events and session persistence.
15
16mod agent_lifecycle;
17mod commands;
18mod context;
19mod input;
20mod onboarding;
21#[cfg(test)]
22pub use agent_lifecycle::{handle_agent_event, remember_input};
23use commands::{handle_command, handle_running_command};
24
25pub use context::CONTEXT_INSPECTION_MAX_ITEMS;
26pub use context::start_auto_compaction;
27
28#[cfg(test)]
29use input::accept_model_suggestion;
30
31pub use commands::command_suggestions_for_app;
32pub use input::{FilePickerSource, Mode, PickerItem, PickerState, PromptAccessory};
33pub use onboarding::setup_model_options;
34pub use onboarding::{ChatGptOAuthDriver, ChatGptOAuthMethod, ChatGptOAuthRecovery, FirstRunRecovery, RecoveryStage};
35
36use input::{
37    handle_key, handle_mouse, offline_model_picker_items, open_model_picker, open_reasoning_effort_picker,
38    open_skill_picker, submit_user_turn,
39};
40use onboarding::{
41    PendingSetupReasoningEffort, advance_after_setup_model_config, handle_first_run_key, poll_chatgpt_oauth_on_tick,
42    provider_authenticated, provider_for_model, selected_provider_missing,
43};
44
45#[cfg(test)]
46mod tests;
47
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50use std::time::{Duration, Instant};
51
52use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
53use serde::{Deserialize, Serialize};
54use thndrs_agent::CancelToken;
55use thndrs_agent::ProviderRequestAccounting;
56pub use thndrs_agent::ToolStatus;
57use thndrs_agent::context::{self as agent_context, CompactionConfig, CompactionPolicy};
58use thndrs_agent::context::{
59    CompactionSummaryCandidate, ContextItemKind, ContextVisibility, HarnessCandidate, InstructionCandidate,
60    PinnedCandidate, SelectionInput, SkillCandidate, TranscriptCandidate, UserTurnCandidate,
61};
62
63use crate::acp::config::provider_label;
64use crate::acp::permissions::{PendingPermission, PermissionDecision};
65use crate::cli::commands::auth::CredentialScope;
66use crate::cli::commands::setup::SetupProviderArg;
67use crate::cli::git::{self, GitStatusSummary};
68use crate::cli::input::history::{INPUT_HISTORY_LIMIT, InputHistoryStore};
69use crate::cli::{Cli, MIN_TICK_RATE_MS, ReasoningEffort, Theme, WebSearchMode};
70use crate::input::PromptInput;
71use crate::providers::{codex, opencode, umans};
72use crate::thndrs_core::auth;
73use crate::tools::shell::ProcessRegistry;
74use crate::{config, fuzzy, internals, prompt, session, skills, tools, utils};
75
76/// How long a Ctrl+D quit confirmation stays armed.
77const QUIT_CONFIRM_TIMEOUT_MS: u64 = 3_000;
78/// How long the UI waits for an agent to acknowledge cancellation before
79/// releasing the stopped prompt.
80const STOPPING_GRACE_MS: u64 = 250;
81
82pub const VISIBLE_ROWS: usize = 8;
83
84/// Shared cap for large filesystem-backed picker inventories so fuzzy matching
85/// stays responsive while still surfacing enough nearby files or skills.
86const LARGE_PICKER_LIMIT: usize = 200;
87const MODEL_PICKER_LIMIT: usize = 50;
88
89/// Semantic run state, used for the status line.
90#[derive(Clone, Debug, Eq, PartialEq, Default)]
91pub enum RunState {
92    /// Nothing in flight.
93    #[default]
94    Idle,
95    /// Agent stream active.
96    Working,
97    /// A stop has been requested via Escape; stream is winding down.
98    Stopping,
99    /// A recoverable error occurred; the prompt is editable again.
100    Error(String),
101}
102
103/// The user-facing prompt state, derived from [`RunState`] and the transcript.
104///
105/// This drives prompt-line styling (color, hint text) to show editable,
106/// submitted, streaming, stopped, errored.
107#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
108pub enum PromptState {
109    /// Idle — user can type and submit.
110    #[default]
111    Editable,
112    /// Prompt submitted; waiting for the first agent event.
113    Submitted,
114    /// Agent is actively streaming reasoning or assistant text.
115    Streaming,
116    /// Agent is executing a tool call.
117    RunningTool,
118    /// Run was cancelled; prompt is editable again.
119    Stopped,
120    /// Run failed with an error; prompt is editable again.
121    Errored,
122}
123
124/// Client-observed time-to-first-token state for the active and last turn.
125#[derive(Clone, Debug, Default)]
126pub struct TurnTtftState {
127    pending_since: Option<Instant>,
128    last_completed: Option<Duration>,
129}
130
131impl TurnTtftState {
132    /// Start timing a newly submitted local user turn.
133    pub fn start_turn(&mut self) {
134        self.pending_since = Some(Instant::now());
135    }
136
137    /// Stop timing on the first semantic provider output.
138    pub fn stop_on_semantic_output(&mut self) {
139        if let Some(started_at) = self.pending_since.take() {
140            self.last_completed = Some(started_at.elapsed());
141        }
142    }
143
144    /// Clear an unfinished pending measurement without replacing the last one.
145    pub fn clear_pending(&mut self) {
146        self.pending_since = None;
147    }
148
149    /// Whether the current turn is still waiting for semantic output.
150    pub fn is_pending(&self) -> bool {
151        self.pending_since.is_some()
152    }
153
154    /// Last successfully measured TTFT.
155    pub fn last_completed(&self) -> Option<Duration> {
156        self.last_completed
157    }
158
159    #[cfg(test)]
160    pub fn set_pending_for_test(&mut self) {
161        self.pending_since = Some(Instant::now());
162    }
163
164    #[cfg(test)]
165    pub fn set_last_completed_for_test(&mut self, duration: Duration) {
166        self.pending_since = None;
167        self.last_completed = Some(duration);
168    }
169}
170
171/// Where input submitted during an active run should be queued.
172#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
173pub enum QueueTarget {
174    /// Inject before the next provider request in the active run.
175    Steering,
176    /// Submit as a new user turn after the active run finishes.
177    #[default]
178    FollowUp,
179}
180
181impl QueueTarget {
182    pub fn toggle(self) -> Self {
183        match self {
184            QueueTarget::Steering => QueueTarget::FollowUp,
185            QueueTarget::FollowUp => QueueTarget::Steering,
186        }
187    }
188
189    pub fn label(self) -> &'static str {
190        match self {
191            QueueTarget::Steering => "steering",
192            QueueTarget::FollowUp => "follow-up",
193        }
194    }
195}
196
197/// Lines to render when a tool detail pane is open.
198///
199/// The detail pane expands a transcript [`Entry::Tool`] into a scrollable
200/// surface so the user can read full tool output without leaving the TUI.
201/// It tracks which transcript entry (by index) is expanded and the current
202/// scroll offset within that entry's rendered output rows.
203#[derive(Clone, Debug, Default, Eq, PartialEq)]
204pub struct DetailPane {
205    /// Index into `app.transcript` for the expanded tool entry.
206    pub entry_index: usize,
207    /// Scroll offset: number of rendered output rows skipped from the top.
208    pub scroll: usize,
209    /// Whether the detail pane is currently open.
210    pub open: bool,
211}
212
213impl DetailPane {
214    /// Scroll up one line, clamped at zero.
215    pub fn scroll_up(&mut self) {
216        self.scroll = self.scroll.saturating_sub(1);
217    }
218
219    /// Scroll down one rendered row.
220    ///
221    /// The renderer clamps this value once terminal width and wrapped
222    /// row count are known.
223    pub fn scroll_down(&mut self, total: usize) {
224        if total > 0 {
225            self.scroll = self.scroll.saturating_add(1);
226        }
227    }
228}
229
230/// One transcript row.
231#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
232pub enum Entry {
233    /// User-submitted text.
234    User { text: String },
235    /// Agent text, possibly still streaming.
236    Agent { text: String, streaming: bool },
237    /// Reasoning/thinking text, kept separate from final assistant text.
238    Reasoning { text: String, streaming: bool },
239    /// A tool call block.
240    Tool {
241        name: String,
242        arguments: String,
243        status: ToolStatus,
244        output: Vec<String>,
245    },
246    /// A status row (e.g. context sources loaded).
247    Status { text: String },
248    /// An error row.
249    Error { text: String },
250}
251
252/// Events from the background agent stream.
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub enum AgentEvent {
255    Started,
256    Status(String),
257    Usage {
258        input_tokens: u64,
259        output_tokens: u64,
260    },
261    /// One successful provider request with exact size and optional usage.
262    RequestAccounting(Box<ProviderRequestAccounting>),
263    AssistantDelta(String),
264    ReasoningDelta(String),
265    ToolStarted {
266        id: String,
267        name: String,
268        arguments: String,
269    },
270    ToolFinished {
271        id: String,
272        output: Vec<String>,
273        status: ToolStatus,
274        /// Structured write result if this was a file-write tool, else `None`.
275        write_result: Option<tools::WriteResult>,
276        /// Structured shell result if this was a `run_shell` tool, else `None`.
277        /// Boxed to avoid a large enum variant (`ProcessResult` carries multiple `Vec<String>` values).
278        shell_result: Option<Box<tools::shell::ProcessResult>>,
279    },
280    ModelMetadataLoaded(Vec<(String, String)>),
281    Retrying {
282        attempt: u32,
283        max_attempts: u32,
284        delay_ms: u64,
285        error: String,
286    },
287    PermissionRequest(PendingPermission),
288    PermissionResolved {
289        tool_call_id: String,
290        outcome: String,
291    },
292    AcpSession(session::AcpSessionMetadata),
293    Finished,
294    Failed(String),
295    Cancelled,
296}
297
298/// The single message type fed into `update`.
299#[derive(Clone, Debug, Eq, PartialEq)]
300pub enum Msg {
301    /// A raw key event from the terminal.
302    Key(crossterm::event::KeyEvent),
303    /// A raw mouse event from the terminal.
304    Mouse(crossterm::event::MouseEvent),
305    /// Periodic tick.
306    Tick,
307    /// Clear the transcript.
308    Clear,
309    /// Quit the app.
310    Quit,
311    /// An agent stream event.
312    Agent(AgentEvent),
313    /// Updated git working tree summary from the background watcher.
314    GitStatusChanged(Option<GitStatusSummary>),
315}
316
317/// The full application state used to draw the screen.
318#[derive(Debug)]
319pub struct App {
320    /// Snapshot of the effective CLI config used by command-like TUI flows.
321    pub cli: Cli,
322    pub session_id: String,
323    pub mode: Mode,
324    pub run_state: RunState,
325    pub input: PromptInput,
326    /// Submitted prompt history for Up/Down recall.
327    pub input_history: Vec<String>,
328    /// Dedicated workspace-local persistence for submitted prompt recall.
329    input_history_store: InputHistoryStore,
330    /// Current index into `input_history` while navigating history.
331    pub history_cursor: Option<usize>,
332    /// Draft input captured before history navigation starts.
333    pub history_draft: String,
334    pub transcript: Vec<Entry>,
335    pub cwd: PathBuf,
336    /// Current git working tree summary for the status line.
337    pub git_status: Option<GitStatusSummary>,
338    pub model: String,
339    pub model_picker_items: Vec<PickerItem>,
340    pub user_label: String,
341    pub websearch: WebSearchMode,
342    /// UI color theme.
343    pub theme: Theme,
344    /// Whether diagnostic provider/log status rows should be shown in transcript.
345    pub verbose: bool,
346    /// Provider token usage accumulated for this session.
347    pub session_tokens_in: u64,
348    pub session_tokens_out: u64,
349    /// In-memory client-observed TTFT for the active and last completed turn.
350    pub ttft: TurnTtftState,
351    /// Loaded context sources (e.g. AGENTS.md).
352    pub context_sources: Vec<crate::context::ContextSource>,
353    /// Filesystem discovery diagnostics for project instructions.
354    pub context_diagnostics: Vec<crate::context::InstructionDiagnostic>,
355    /// Latest provider-neutral context ledger used for inspection and prompt
356    /// assembly. It is replaced at each turn boundary.
357    pub context_ledger: Option<agent_context::ContextLedger>,
358    /// Most recent completed provider request accounting for `/tokens` and
359    /// context export. The request projection is in-memory only.
360    pub last_request_accounting: Option<ProviderRequestAccounting>,
361    /// Discovered Agent Skills metadata.
362    pub skills: Vec<skills::SkillMetadata>,
363    /// Skill discovery diagnostics for ignored malformed skills.
364    pub skill_diagnostics: Vec<skills::SkillDiagnostic>,
365    /// Monotonic UI tick used for lightweight animated affordances.
366    pub ui_tick: u64,
367    /// When `Some`, the user pressed Ctrl+D once and we are waiting for a
368    /// second press within roughly three seconds to actually quit. The value
369    /// is the tick deadline at which the pending confirmation expires.
370    pub ctrl_d_pending: Option<u64>,
371    /// Tick deadline that bounds how long a cancelled run may remain in the
372    /// `Stopping` state while its worker unwinds.
373    pub stopping_deadline: Option<u64>,
374    /// Append-only session writer. `None` when persistence is disabled
375    /// (e.g. the sessions directory is not writable).
376    pub session_writer: Option<session::SessionWriter>,
377    /// Tool-call ids mapped to their bounded redacted recovery handles.
378    pub tool_artifacts: HashMap<String, String>,
379    /// Monotonic turn counter for session record correlation.
380    pub turn_count: u64,
381    /// Registry of background processes started via `run_shell`.
382    pub process_registry: ProcessRegistry,
383    /// The active provider prompt, retained so user input can be restored on
384    /// provider failure. This can be an internal compaction request that is
385    /// intentionally absent from the visible transcript. Cleared on successful
386    /// completion.
387    pub last_input: Option<String>,
388    /// In-flight compaction (manual or automatic). The original active
389    /// context is retained until the configured provider summary and audit
390    /// record both succeed.
391    pending_manual_compaction: Option<context::PendingManualCompaction>,
392    /// A generated summary awaiting explicit review before it changes the
393    /// active working set.
394    pending_compaction_review: Option<context::PendingCompactionReview>,
395    /// Last compaction review state for the context health surface.
396    pub last_compaction_review: Option<session::CompactionReviewResult>,
397    /// Task-local pins retained across turn-boundary ledger rebuilds.
398    context_pins: Vec<PinnedCandidate>,
399    /// Explicitly dropped context ids retained until the user recovers or
400    /// resets them.
401    context_dropped_ids: Vec<String>,
402    /// Summaries that can stand in for older transcript entries.
403    compaction_summaries: Vec<CompactionSummaryCandidate>,
404    /// Current target for input submitted while the agent is running.
405    pub queue_target: QueueTarget,
406    /// Setup state to resume after choosing a GPT-5.6 reasoning effort.
407    pending_setup_reasoning_effort: Option<PendingSetupReasoningEffort>,
408    /// Active fuzzy picker state, used by file and model pickers.
409    pub picker: Option<PickerState>,
410    /// Inline prompt accessory rendered above the input.
411    pub prompt_accessory: PromptAccessory,
412    /// Focused first-run or credential recovery surface.
413    pub first_run_recovery: Option<FirstRunRecovery>,
414    /// ChatGPT OAuth functions used by focused recovery.
415    pub chatgpt_oauth_driver: ChatGptOAuthDriver,
416    /// Short-lived browser PKCE callback owned by the application adapter.
417    pub chatgpt_browser_login: Option<auth::ChatGptCodexBrowserLogin>,
418    /// Steering messages waiting to be sent to the active agent thread.
419    pub queued_steering: Vec<String>,
420    /// Follow-up prompts to submit as new turns after the active run completes.
421    pub queued_followups: Vec<String>,
422    /// Kill-ring for readline-style yank (Ctrl+Y).
423    pub kill_ring: Vec<String>,
424    /// Scrollable detail pane for inspecting full tool output.
425    pub detail_pane: DetailPane,
426    /// One pending ACP permission request, if an external agent is blocked on a user decision.
427    pub pending_permission: Option<PendingPermission>,
428    /// Non-fatal config diagnostics from effective config loading. Surfaced in
429    /// verbose startup rows and prompt inspection.
430    pub config_diagnostics: Vec<String>,
431    /// MCP config files captured when the session audit metadata was written.
432    pub mcp_config_files: Vec<session::SessionConfigFile>,
433    /// Non-fatal MCP config diagnostics from the latest MCP config audit load.
434    pub mcp_config_diagnostics: Vec<String>,
435    /// When true the loop should stop and the app exit.
436    pub quit: bool,
437}
438
439impl From<&Cli> for App {
440    fn from(value: &Cli) -> Self {
441        let workspace_root = crate::context::discover_workspace_root(&value.cwd);
442        let mut cli_snapshot = value.clone();
443        cli_snapshot.cwd = workspace_root.clone();
444        cli_snapshot.tick_rate_ms = cli_snapshot.tick_rate_ms.max(MIN_TICK_RATE_MS);
445        let context_inventory = crate::context::discover_instructions(&workspace_root);
446        let context_sources = context_inventory.sources;
447        let context_diagnostics = context_inventory.diagnostics;
448        let skill_inventory = skills::discover(&workspace_root, &value.skill_dirs);
449        let transcript = Vec::new();
450        let sessions_dir = value
451            .session_dir
452            .clone()
453            .unwrap_or_else(|| session::sessions_dir(&workspace_root));
454        let session_id = session::generate_session_id();
455        let input_history_store = InputHistoryStore::for_workspace(&workspace_root);
456        let input_history = input_history_store.load_recent().ok().flatten().unwrap_or_default();
457        let (mcp_config_files, mcp_config_diagnostics) = agent_lifecycle::load_mcp_config_audit(&workspace_root);
458
459        let config_meta = {
460            let files: Vec<session::SessionConfigFile> = value
461                .config_layers
462                .iter()
463                .filter_map(|layer| {
464                    let path = layer.display_path.as_ref()?;
465                    Some(session::SessionConfigFile {
466                        path: path.clone(),
467                        source: layer.source.as_str().to_string(),
468                        sha256: layer.hash.clone().unwrap_or_default(),
469                    })
470                })
471                .collect();
472            let origins: std::collections::BTreeMap<String, String> = value
473                .config_origins
474                .iter()
475                .map(|(key, origin)| (key.clone(), format!("{}:{}", origin.source.as_str(), origin.detail)))
476                .collect();
477            let diagnostics = value.config_diagnostics.clone();
478            let session_dir = Some(sessions_dir.display().to_string());
479            Some(session::SessionConfigMeta {
480                session_dir,
481                files,
482                origins,
483                diagnostics,
484                mcp_files: mcp_config_files.clone(),
485                mcp_diagnostics: mcp_config_diagnostics.clone(),
486            })
487        };
488
489        let mut session_writer = session::SessionWriter::create(
490            &sessions_dir,
491            &session_id,
492            &workspace_root.display().to_string(),
493            "scratch",
494            provider_label(&value.model),
495            &value.model,
496            value.websearch.label(),
497            env!("CARGO_PKG_VERSION"),
498            config_meta,
499        )
500        .ok();
501
502        if let Some(ref mut writer) = session_writer.as_mut()
503            && !context_sources.is_empty()
504        {
505            let _ = writer.append_context(&context_sources);
506        }
507
508        let mut app = App {
509            cli: cli_snapshot,
510            session_id,
511            mode: Mode::default(),
512            run_state: RunState::default(),
513            input: PromptInput::new(),
514            input_history,
515            input_history_store,
516            history_cursor: None,
517            history_draft: String::new(),
518            transcript,
519            git_status: git::collect(&workspace_root),
520            cwd: workspace_root,
521            model: value.model.clone(),
522            model_picker_items: offline_model_picker_items(),
523            user_label: default_user_label(),
524            websearch: value.websearch,
525            theme: value.theme,
526            verbose: value.verbose,
527            session_tokens_in: 0,
528            session_tokens_out: 0,
529            ttft: TurnTtftState::default(),
530            context_sources,
531            context_diagnostics,
532            context_ledger: None,
533            last_request_accounting: None,
534            skills: skill_inventory.skills,
535            skill_diagnostics: skill_inventory.diagnostics,
536            ui_tick: 0,
537            ctrl_d_pending: None,
538            stopping_deadline: None,
539            session_writer,
540            tool_artifacts: HashMap::new(),
541            turn_count: 0,
542            process_registry: ProcessRegistry::new(),
543            last_input: None,
544            pending_manual_compaction: None,
545            pending_compaction_review: None,
546            last_compaction_review: None,
547            context_pins: Vec::new(),
548            context_dropped_ids: Vec::new(),
549            compaction_summaries: Vec::new(),
550            queue_target: QueueTarget::default(),
551            pending_setup_reasoning_effort: None,
552            picker: None,
553            prompt_accessory: PromptAccessory::None,
554            first_run_recovery: None,
555            chatgpt_oauth_driver: ChatGptOAuthDriver::default(),
556            chatgpt_browser_login: None,
557            queued_steering: Vec::new(),
558            queued_followups: Vec::new(),
559            kill_ring: Vec::new(),
560            detail_pane: DetailPane::default(),
561            pending_permission: None,
562            config_diagnostics: value.config_diagnostics.clone(),
563            mcp_config_files,
564            mcp_config_diagnostics,
565            quit: false,
566        };
567
568        app.first_run_recovery = selected_provider_missing(&app);
569        app
570    }
571}
572
573impl App {
574    /// Build the initial app from parsed CLI args.
575    ///
576    /// Discovers the workspace root from `--cwd` (preferring the git root), loads
577    /// scoped `AGENTS.md` sources if present, and records their metadata in the session.
578    pub fn from_cli(cli: &Cli) -> Self {
579        cli.into()
580    }
581
582    /// Whether a compaction turn is currently in flight.
583    ///
584    /// Used by the preflight gate to avoid re-triggering auto-compaction while
585    /// the configured-model summary request is the active turn.
586    pub fn compaction_in_flight(&self) -> bool {
587        self.pending_manual_compaction.is_some()
588    }
589
590    /// Return the local bounded artifact store for this session workspace.
591    ///
592    /// The store is deliberately separate from JSONL so session records carry
593    /// metadata and handles without making artifact bodies part of replay truth.
594    pub fn artifact_store(&self) -> crate::artifacts::ArtifactStore {
595        crate::artifacts::ArtifactStore::new(self.session_directory().join("artifacts"))
596    }
597
598    /// Render the bounded `/tokens` inspection projection.
599    pub fn token_accounting_status(&self) -> String {
600        let Some(accounting) = &self.last_request_accounting else {
601            return format!(
602                "tokens\nsession totals: in {} out {}\nrequest accounting: unavailable",
603                self.session_tokens_in, self.session_tokens_out
604            );
605        };
606        let estimate = accounting
607            .estimated_input_tokens
608            .value
609            .map_or_else(|| "unknown".to_string(), |value| value.to_string());
610        let estimate_source = match &accounting.estimated_input_tokens.provenance {
611            thndrs_agent::MeasurementProvenance::Estimated { version, .. } => format!("estimated/{version}"),
612            _ => "unknown".to_string(),
613        };
614        let Some(usage) = accounting.provider_usage.as_ref() else {
615            return format!(
616                "tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider: unknown\nshadow receipts: {}",
617                accounting.request_id,
618                accounting.attempt,
619                accounting.serialized_bytes.value,
620                estimate,
621                estimate_source,
622                accounting.shadow_receipts.len()
623            );
624        };
625        let inclusive = usage
626            .inclusive_input_tokens
627            .value
628            .map_or_else(|| "unknown".to_string(), |value| value.to_string());
629        let estimate_error = match (
630            accounting.estimated_input_tokens.value,
631            usage.inclusive_input_tokens.value,
632        ) {
633            (Some(estimated), Some(provider)) => (provider as i128 - estimated as i128).to_string(),
634            _ => "unknown".to_string(),
635        };
636        format!(
637            "tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider {} reported: {} input / {} output\ncache: {} read / {} create\nnormalized input: {} ({})\nestimate error: {} tokens\nshadow receipts: {}",
638            accounting.request_id,
639            accounting.attempt,
640            accounting.serialized_bytes.value,
641            estimate,
642            estimate_source,
643            usage.provider,
644            display_token(usage.components.input_tokens),
645            display_token(usage.components.output_tokens),
646            display_token(usage.components.cache_read_input_tokens),
647            display_token(usage.components.cache_creation_input_tokens),
648            inclusive,
649            usage.rule.label(),
650            estimate_error,
651            accounting.shadow_receipts.len()
652        )
653    }
654
655    /// Build the compact self-knowledge snapshot used by the startup display.
656    pub fn self_knowledge_snapshot(&self) -> internals::SelfKnowledgeSnapshot {
657        let tools = tools::tool_definitions();
658        let provider = internals::ProviderSnapshot::new(provider_label(&self.model), &self.model, self.websearch);
659        let runtime = internals::RuntimeSnapshot::new(
660            provider,
661            self.cwd.display().to_string(),
662            internals::RENDERER_MODE,
663            tools.iter().map(|tool| tool.name.to_string()).collect(),
664        );
665        let references = internals::ReferenceSnapshot::from_skills(&self.skills);
666        let prompt_context = internals::PromptContextSnapshot::new(
667            prompt::default_fragments()
668                .into_iter()
669                .map(|fragment| fragment.name.to_string())
670                .collect(),
671            &self.context_sources,
672        );
673        let inventory = internals::KnowledgeInventorySnapshot::new(references, prompt_context);
674        let mut diagnostics: Vec<String> = self
675            .skill_diagnostics
676            .iter()
677            .map(skills::SkillDiagnostic::summary)
678            .collect();
679        diagnostics.extend(self.config_diagnostics.iter().cloned());
680        diagnostics.extend(self.mcp_config_diagnostics.iter().cloned());
681        internals::SelfKnowledgeSnapshot::new(
682            internals::AppIdentitySnapshot::default(),
683            runtime,
684            inventory,
685            diagnostics,
686        )
687    }
688
689    /// Derive the granular status label for the status line.
690    ///
691    /// Maps `RunState` plus the last transcript entry into one of idle, sending,
692    /// thinking, working, running tool, stopping, cancelled, failed, error, done.
693    pub fn status_label(&self) -> &'static str {
694        match self.run_state {
695            RunState::Working => match self.transcript.last() {
696                Some(Entry::Reasoning { streaming: true, .. }) => "thinking",
697                Some(Entry::Agent { streaming: true, .. }) => "working",
698                Some(Entry::Tool { status: ToolStatus::Running, .. }) => "running tool",
699                Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled tool",
700                Some(Entry::User { .. }) | None => "sending",
701                _ => "working",
702            },
703            RunState::Stopping => "stopping",
704            RunState::Error(_) => "failed",
705            RunState::Idle => match self.transcript.last() {
706                Some(Entry::Status { text }) if text == "cancelled" => "cancelled",
707                _ => match self.last_non_status_entry() {
708                    Some(Entry::Error { .. }) => "failed",
709                    Some(Entry::Tool { status: ToolStatus::Failed, .. }) => "failed",
710                    Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled",
711                    Some(Entry::Agent { streaming: false, .. }) | Some(Entry::Tool { status: ToolStatus::Ok, .. }) => {
712                        "done"
713                    }
714                    _ => "idle",
715                },
716            },
717        }
718    }
719
720    /// Derive the prompt UI state from `run_state` and the transcript.
721    pub fn prompt_state(&self) -> PromptState {
722        match self.run_state {
723            RunState::Working => match self.transcript.last() {
724                Some(Entry::Reasoning { streaming: true, .. }) => PromptState::Streaming,
725                Some(Entry::Agent { streaming: true, .. }) => PromptState::Streaming,
726                Some(Entry::Tool { status: ToolStatus::Running, .. }) => PromptState::RunningTool,
727                _ => PromptState::Submitted,
728            },
729            RunState::Stopping => PromptState::Stopped,
730            RunState::Error(_) => PromptState::Errored,
731            RunState::Idle => match self.transcript.last() {
732                Some(Entry::Status { text }) if text == "cancelled" => PromptState::Stopped,
733                _ => match self.last_non_status_entry() {
734                    Some(Entry::Error { .. }) => PromptState::Errored,
735                    _ => PromptState::Editable,
736                },
737            },
738        }
739    }
740
741    fn last_non_status_entry(&self) -> Option<&Entry> {
742        self.transcript
743            .iter()
744            .rev()
745            .find(|entry| !matches!(entry, Entry::Status { .. }))
746            .or_else(|| self.transcript.last())
747    }
748
749    fn refresh_git_status(&mut self) {
750        self.git_status = git::collect(&self.cwd);
751    }
752
753    fn session_directory(&self) -> PathBuf {
754        self.cli
755            .session_dir
756            .clone()
757            .unwrap_or_else(|| session::sessions_dir(&self.cwd))
758    }
759
760    /// Resolve the configured compaction policy from loaded config layers.
761    pub fn effective_compaction_policy(&self) -> CompactionPolicy {
762        let config = self
763            .cli
764            .config_layers
765            .iter()
766            .map(|layer| &layer.config.context.compaction)
767            .rev()
768            .find(|config| **config != CompactionConfig::default())
769            .cloned()
770            .unwrap_or_default();
771        CompactionPolicy::from_config(&config)
772    }
773}
774
775fn display_token(value: Option<u64>) -> String {
776    value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
777}
778
779/// The only mutation path. Returns an optional follow-up message.
780pub fn update(app: &mut App, msg: &Msg) -> Option<Msg> {
781    match msg {
782        Msg::Key(key) => handle_key(app, *key),
783        Msg::Mouse(mouse) => handle_mouse(app, *mouse),
784        Msg::Quit => {
785            let results = app.process_registry.shutdown();
786            agent_lifecycle::record_background_results(app, results);
787            app.quit = true;
788            None
789        }
790        Msg::Tick => {
791            app.ui_tick = app.ui_tick.wrapping_add(1);
792            if let Some(deadline) = app.ctrl_d_pending
793                && agent_lifecycle::now_or_after_deadline(app.ui_tick, deadline)
794            {
795                app.ctrl_d_pending = None;
796            }
797            agent_lifecycle::drain_background_processes(app);
798            agent_lifecycle::finish_stopping_if_due(app);
799            poll_chatgpt_oauth_on_tick(app);
800            None
801        }
802        Msg::Clear => {
803            app.transcript.clear();
804            app.detail_pane = DetailPane::default();
805            None
806        }
807        Msg::Agent(event) => agent_lifecycle::handle_agent_event(app, event.clone()),
808        Msg::GitStatusChanged(status) => {
809            app.git_status = status.clone();
810            None
811        }
812    }
813}
814
815fn default_user_label() -> String {
816    std::env::var("USER")
817        .or_else(|_| std::env::var("USERNAME"))
818        .map(|name| format!("User ({name})"))
819        .unwrap_or_else(|_| String::from("You"))
820}
821
822fn is_verbose_status(text: &str) -> bool {
823    text.starts_with("provider:") || text.starts_with("logs  ") || text.starts_with("tool budget:")
824}
825
826/// Translate the fixed Ctrl+D confirmation window to the configured tick cadence.
827///
828/// This keeps the user-visible timeout stable when a faster render cadence is
829/// selected for smoother streaming output.
830fn quit_confirm_timeout_ticks(app: &App) -> u64 {
831    let tick_ms = app.cli.tick_rate_ms.max(1);
832    QUIT_CONFIRM_TIMEOUT_MS / tick_ms + u64::from(!QUIT_CONFIRM_TIMEOUT_MS.is_multiple_of(tick_ms))
833}