Skip to main content

mermaid_cli/domain/
state.rs

1//! The single state shape for the whole application.
2//!
3//! `State` is the value the reducer operates on. Everything the UI
4//! shows — from the chat log to the input buffer to the "Thinking…"
5//! animation — is derived from fields in this struct. Mutation happens
6//! only inside `update(state, msg)`; no other code is allowed to hold
7//! a `&mut State`.
8//!
9//! The sub-state enums (`TurnState`, `UiMode`, `McpServerStatus`) are
10//! intentionally explicit sum types. A previous generation of this
11//! codebase used bools like `is_generating: bool`, `is_cancelling:
12//! bool`, `is_tool_call_pending: bool` — the invariants between those
13//! bools were load-bearing and enforced by convention. Expressing the
14//! same state as a single enum makes it impossible to be in two modes
15//! at once, and the reducer can pattern-match instead of guarding with
16//! if-chains.
17
18use std::collections::{HashMap, VecDeque};
19use std::path::PathBuf;
20use std::time::SystemTime;
21
22use chrono::{DateTime, Local};
23
24use crate::app::instructions::LoadedInstructions;
25use crate::app::{Config, McpServerConfig};
26use crate::models::ChatMessage;
27use crate::models::tool_call::ToolCall as ModelToolCall;
28use crate::models::{ProviderContinuation, ReasoningLevel, TokenUsage, TokenUsageSource};
29use crate::runtime::SafetyMode;
30use crate::session::ConversationHistory;
31
32use super::cmd::ChatRequest;
33use super::compaction::CompactionTrigger;
34use super::ids::{IdAllocator, ToolCallId, TurnId};
35use super::msg::Msg;
36use super::question::PendingQuestionSet;
37use super::runtime::{RuntimeState, ToolArtifact, ToolRunMetadata, ToolStatus};
38
39/// Root state. The reducer takes `State` by value, returns a new
40/// `State`, and emits any side-effects as a `Vec<Cmd>`. No `&mut` — a
41/// deliberate choice so tests can diff before/after without aliasing
42/// worries, and so replay ("compute the final State that this Msg log
43/// would produce") is a straight fold.
44#[derive(Debug, Clone)]
45pub struct State {
46    pub session: Session,
47    pub turn: TurnState,
48    pub ui: UiState,
49    pub mcp: McpState,
50    pub settings: Config,
51    pub instructions: Option<LoadedInstructions>,
52    /// Durable semantic memory snapshot (auto-derived index + entries),
53    /// refreshed per turn like `instructions`. Its index is injected into the
54    /// model prompt alongside project instructions.
55    pub memory: Option<crate::app::memory::LoadedMemory>,
56    /// Discovered SKILL.md playbooks (project/user/plugin) plus the rendered
57    /// index injected into the model prompt alongside instructions and memory.
58    /// Loaded once at startup — skills are authored artifacts, not live state.
59    pub skills: Option<crate::app::skills::LoadedSkills>,
60    /// Context strings injected by `before_tool_use` plugin hooks
61    /// (`additionalContext`), buffered until the next dispatched model
62    /// request consumes them (see `push_call_model`). Byte-capped; transient
63    /// (never persisted with the session).
64    pub pending_hook_context: Vec<String>,
65    /// One-line notices about the task checklist for the model's next
66    /// request: user `/todos` edits, vetoed completions, staleness nudges.
67    /// Same lifecycle as `pending_hook_context` (consumed by the next real
68    /// dispatch, transient, never persisted).
69    pub pending_task_notices: Vec<String>,
70    /// Current working directory. Captured once at startup; tools
71    /// receive it via `ExecContext::workdir` and spawned subprocesses
72    /// inherit it. Centralized here so tests can inject a fake cwd.
73    pub cwd: PathBuf,
74    /// System temp dir, captured once at startup (`std::env::temp_dir()`).
75    /// Pasted-image attachments build their scratch path from it; holding it
76    /// here keeps the reducer free of the env read it used to do inline (#54).
77    pub temp_dir: PathBuf,
78    pub ids: IdAllocatorBundle,
79    /// When `Some`, the next render should pop up a modal confirmation
80    /// (e.g. "are you sure you want to /clear?"). Cleared by the
81    /// reducer when the user answers.
82    pub confirm: Option<Confirmation>,
83    /// FIFO queue of tool actions awaiting the user's inline approval
84    /// (interactive `ask` mode + Auto-mode escalations). The front item is
85    /// rendered as a modal; answering it pops the item and emits
86    /// `Cmd::ResolveApproval`, which unblocks the parked tool task. Empty in
87    /// headless mode (no broker → the out-of-band `/approve` flow instead).
88    pub pending_approval: VecDeque<PendingApproval>,
89    /// FIFO queue of `ask_user_question` batches awaiting the user's answers.
90    /// The front item renders as a selectable modal; submitting pops it and
91    /// emits `Cmd::ResolveQuestion`, unblocking the parked tool task. Empty in
92    /// headless mode (no broker → the tool proceeds without asking).
93    pub pending_question: VecDeque<PendingQuestionSet>,
94    /// Runtime-only observability state: process registry, provider
95    /// capability snapshot, and lifecycle timeline. Not sent to the
96    /// model.
97    pub runtime: RuntimeState,
98    /// Quit flag. When set, the main loop drains pending effects and
99    /// exits. The reducer never panics on its own; it sets this instead.
100    pub should_exit: bool,
101    /// Prompt-backed slash commands contributed by enabled plugins
102    /// (`manifest.prompts`). Loaded once at startup by the run loop (like
103    /// `skills`); the reducer expands `/name args` into a normal
104    /// `Msg::SubmitPrompt`, so recordings replay without the plugin
105    /// installed. Sorted by name.
106    pub plugin_commands: Vec<PluginCommand>,
107    /// `mermaid run --output-schema`: set by the headless driver before the
108    /// dedicated formatting turn; `build_chat_request` copies it onto the
109    /// request (dropping all tools for that turn). Never set interactively.
110    pub output_schema: Option<serde_json::Value>,
111    /// Wall-clock for the current reducer step, injected as data (Cause 3).
112    /// The driver stamps this once per tick — `Local::now()` live, or the
113    /// recorded entry's `ts` on replay — *before* calling `update`. The
114    /// reducer and the `transition` helpers read `state.now` instead of
115    /// `Local::now()` / `SystemTime::now()`, so `update(State, Msg)` is a pure
116    /// function of its inputs: the same `(State, Msg)` always yields the same
117    /// `State`, and folding a recorded `Msg` log recomputes State exactly.
118    pub now: DateTime<Local>,
119}
120
121impl State {
122    /// Build a fresh state tied to a specific model + project dir.
123    ///
124    /// Pure given its inputs: `now` seeds the injected clock and derives the
125    /// initial conversation's id/title, so `--replay` reconstructs the same
126    /// starting state from a recorded header. (The one environment read left
127    /// is `env::temp_dir()` — stable within a machine, and only feeds paste
128    /// scratch paths.) Nothing here touches the filesystem or tokio.
129    pub fn new(settings: Config, cwd: PathBuf, model_id: String, now: DateTime<Local>) -> Self {
130        let project_path = cwd.display().to_string();
131        let conversation = ConversationHistory::new(project_path, model_id.clone(), now);
132        let initial_title = conversation.title.clone();
133        // F5: seed `mcp.servers` from the user's configured MCP
134        // servers with `Starting` status. Previously the map started
135        // empty, and `McpServerReady` handlers used `get_mut` —
136        // configured servers never populated, so their tools never
137        // reached `build_chat_request`'s outgoing tool list.
138        let mcp = {
139            let mut m = McpState::default();
140            for (name, cfg) in &settings.mcp_servers {
141                m.servers.insert(
142                    name.clone(),
143                    McpServerEntry {
144                        config: cfg.clone(),
145                        status: McpServerStatus::Starting,
146                        tools: Vec::new(),
147                    },
148                );
149            }
150            m
151        };
152        // F11: honor the per-model reasoning preference (persisted via
153        // `/reasoning high` while using a specific model). Falls back to
154        // the global default when no entry exists.
155        let reasoning = settings
156            .reasoning_per_model
157            .get(&model_id)
158            .copied()
159            .unwrap_or(settings.default_model.reasoning);
160        let runtime = RuntimeState::new(&model_id);
161        Self {
162            session: Session {
163                conversation,
164                model_id,
165                reasoning,
166                safety_mode: settings.safety.mode,
167                last_token_usage: None,
168                cumulative_token_usage: TokenUsageTotals::default(),
169                context_usage: None,
170                is_subagent: false,
171                agent_preamble: None,
172                plan: None,
173                // Materialized by the effect layer after startup dispatches
174                // `Cmd::EnsureScratchpad`; the pure constructor never touches
175                // the filesystem.
176                scratchpad: None,
177            },
178            turn: TurnState::Idle,
179            ui: UiState {
180                last_title_dispatched: Some(initial_title),
181                theme: settings.ui.theme,
182                ..UiState::default()
183            },
184            mcp,
185            settings,
186            instructions: None,
187            memory: None,
188            skills: None,
189            pending_hook_context: Vec::new(),
190            pending_task_notices: Vec::new(),
191            cwd,
192            temp_dir: std::env::temp_dir(),
193            ids: IdAllocatorBundle::default(),
194            confirm: None,
195            pending_approval: VecDeque::new(),
196            pending_question: VecDeque::new(),
197            runtime,
198            should_exit: false,
199            output_schema: None,
200            plugin_commands: Vec::new(),
201            // Seed the injected clock from the caller (live: startup wall
202            // clock; replay: the recorded header's ts). The driver overwrites
203            // this on every iteration (Cause 3); the reducer never reads the
204            // wall clock directly.
205            now,
206        }
207    }
208
209    /// Apply a `--continue` / `--sessions` seed: replace the fresh
210    /// conversation with the loaded history and re-dispatch the terminal
211    /// title once. Shared by the live driver and `--replay` so both
212    /// construct the same starting state by definition.
213    pub fn seed_conversation(&mut self, history: ConversationHistory) {
214        let title = history.title.clone();
215        // Restore the live meters + safety mode that ride on the saved file
216        // (see `Session::snapshot_conversation`). Sessions saved before these
217        // fields existed leave them at None/0, so keep the config-default
218        // safety mode (already set by `State::new`) when the file has none.
219        if let Some(mode) = history.safety_mode {
220            self.session.safety_mode = mode;
221        }
222        // Restore planning-in-progress (None for sessions saved before the
223        // field existed, and for sessions that weren't planning).
224        self.session.plan = history.plan.clone();
225        self.session.last_token_usage = history.last_token_usage;
226        self.session.cumulative_token_usage = history.cumulative_token_usage;
227        self.session.context_usage = history.context_usage.clone();
228        self.session.conversation = history;
229        // A session persisted mid-tool (an assistant `tool_use` with no committed
230        // result, or a result whose call was archived out) would otherwise resume
231        // with an orphan and 400 the first request. Repair pairing on the loaded
232        // prefix so both the transcript and the next request are valid.
233        crate::domain::compaction::normalize_history(self.session.conversation.messages_mut());
234        // Checklist retirement deliberately does NOT happen here. There is one
235        // retirement rule and it lives at natural run end
236        // (`handle_stream_done`), where the summary line absorbs the count so
237        // retirement reads as completion rather than data loss.
238        //
239        // Retiring again at seed time made a SECOND rule with different
240        // behavior: run end preserves the list when a run is cancelled or
241        // errors, but a seed-time clear discarded any all-done list on the
242        // next `--resume`/`--continue` — including one the user cancelled and
243        // came back to. The transcript is not a substitute for the checklist
244        // the next run resumes against.
245        // Continue global image numbering past the highest number already in the
246        // loaded transcript, so `[Image #16]` keeps referring to that same image
247        // across --resume/--continue. Sessions saved before image numbering (no
248        // `image_numbers`) yield max 0 → start at 1, the default. Shared live +
249        // --replay seed path, so both reconstruct an identical allocator.
250        let max_image = self
251            .session
252            .conversation
253            .messages()
254            .iter()
255            .filter_map(|m| m.image_numbers.as_ref())
256            .flatten()
257            .copied()
258            .max()
259            .unwrap_or(0);
260        self.ids.image = crate::domain::ids::IdAllocator::starting_at(max_image + 1);
261        self.ui.last_title_dispatched = Some(title);
262    }
263
264    /// True iff the reducer is currently mid-turn. UI uses this for
265    /// the "⏎ cancels generation" hint and for keybind routing.
266    pub fn is_busy(&self) -> bool {
267        !matches!(self.turn, TurnState::Idle)
268    }
269
270    /// The active `TurnId`, if any turn is in flight. The reducer
271    /// filters incoming effect messages by comparing their embedded
272    /// `TurnId` to this value — if the user cancelled and started a
273    /// new turn, stale results from the old turn are dropped cleanly.
274    pub fn current_turn_id(&self) -> Option<TurnId> {
275        self.turn.id()
276    }
277}
278
279/// Per-component token counts accumulated for UI display. Components
280/// are disjoint (mirrors `TokenUsage`); totals are derived, never
281/// stored. Providers report usage per API request; the session keeps
282/// both the last request and the cumulative API usage so the footer
283/// does not imply this is the current model context length.
284#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
285pub struct TokenUsageTotals {
286    pub prompt_tokens: usize,
287    pub completion_tokens: usize,
288    pub cached_input_tokens: usize,
289    pub cache_creation_input_tokens: usize,
290    pub reasoning_output_tokens: usize,
291}
292
293impl TokenUsageTotals {
294    pub fn from_usage(usage: &TokenUsage) -> Self {
295        Self {
296            prompt_tokens: usage.prompt_tokens,
297            completion_tokens: usage.completion_tokens,
298            cached_input_tokens: usage.cached_input_tokens,
299            cache_creation_input_tokens: usage.cache_creation_input_tokens,
300            reasoning_output_tokens: usage.reasoning_output_tokens,
301        }
302    }
303
304    pub fn add_assign(&mut self, other: Self) {
305        self.prompt_tokens = self.prompt_tokens.saturating_add(other.prompt_tokens);
306        self.completion_tokens = self
307            .completion_tokens
308            .saturating_add(other.completion_tokens);
309        self.cached_input_tokens = self
310            .cached_input_tokens
311            .saturating_add(other.cached_input_tokens);
312        self.cache_creation_input_tokens = self
313            .cache_creation_input_tokens
314            .saturating_add(other.cache_creation_input_tokens);
315        self.reasoning_output_tokens = self
316            .reasoning_output_tokens
317            .saturating_add(other.reasoning_output_tokens);
318    }
319
320    pub fn input_total_tokens(&self) -> usize {
321        self.prompt_tokens
322            .saturating_add(self.cached_input_tokens)
323            .saturating_add(self.cache_creation_input_tokens)
324    }
325
326    pub fn output_total_tokens(&self) -> usize {
327        self.completion_tokens
328            .saturating_add(self.reasoning_output_tokens)
329    }
330
331    pub fn total_tokens(&self) -> usize {
332        self.input_total_tokens()
333            .saturating_add(self.output_total_tokens())
334    }
335}
336
337/// Approximate request-context breakdown used before provider usage
338/// arrives. These numbers are diagnostic estimates, not billing facts.
339#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
340pub struct PromptTokenBreakdown {
341    pub system_tokens: usize,
342    pub instructions_tokens: usize,
343    pub message_tokens: usize,
344    pub tool_schema_tokens: usize,
345    pub image_count: usize,
346    pub message_count: usize,
347    pub tool_count: usize,
348}
349
350impl PromptTokenBreakdown {
351    pub fn total_tokens(&self) -> usize {
352        self.system_tokens
353            .saturating_add(self.instructions_tokens)
354            .saturating_add(self.message_tokens)
355            .saturating_add(self.tool_schema_tokens)
356    }
357}
358
359/// The model-visible context for the latest request. This is separate
360/// from cumulative session usage, which is an API/accounting total.
361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
362pub struct ContextUsageSnapshot {
363    pub used_tokens: usize,
364    pub max_tokens: Option<usize>,
365    pub remaining_tokens: Option<usize>,
366    pub used_percent: Option<u8>,
367    pub source: TokenUsageSource,
368    pub prompt_tokens: usize,
369    pub cached_input_tokens: usize,
370    pub cache_creation_input_tokens: usize,
371    pub completion_tokens: usize,
372    pub reasoning_output_tokens: usize,
373    pub breakdown: Option<PromptTokenBreakdown>,
374}
375
376impl ContextUsageSnapshot {
377    pub fn from_usage(usage: &TokenUsage, max_tokens: Option<usize>) -> Self {
378        // input + output ≈ what the next request's prompt will occupy;
379        // derived from disjoint components so it means the same thing
380        // for every provider.
381        Self::new(
382            usage.total_tokens(),
383            max_tokens,
384            usage.source,
385            usage.prompt_tokens,
386            usage.cached_input_tokens,
387            usage.cache_creation_input_tokens,
388            usage.completion_tokens,
389            usage.reasoning_output_tokens,
390            None,
391        )
392    }
393
394    pub fn from_estimate(breakdown: PromptTokenBreakdown, max_tokens: Option<usize>) -> Self {
395        let used = breakdown.total_tokens();
396        Self::new(
397            used,
398            max_tokens,
399            TokenUsageSource::Estimate,
400            used,
401            0,
402            0,
403            0,
404            0,
405            Some(breakdown),
406        )
407    }
408
409    #[allow(clippy::too_many_arguments)]
410    fn new(
411        used_tokens: usize,
412        max_tokens: Option<usize>,
413        source: TokenUsageSource,
414        prompt_tokens: usize,
415        cached_input_tokens: usize,
416        cache_creation_input_tokens: usize,
417        completion_tokens: usize,
418        reasoning_output_tokens: usize,
419        breakdown: Option<PromptTokenBreakdown>,
420    ) -> Self {
421        let remaining_tokens = max_tokens.map(|max| max.saturating_sub(used_tokens));
422        let used_percent = max_tokens
423            .filter(|max| *max > 0)
424            .map(|max| ((used_tokens.saturating_mul(100)) / max).min(100) as u8);
425        Self {
426            used_tokens,
427            max_tokens,
428            remaining_tokens,
429            used_percent,
430            source,
431            prompt_tokens,
432            cached_input_tokens,
433            cache_creation_input_tokens,
434            completion_tokens,
435            reasoning_output_tokens,
436            breakdown,
437        }
438    }
439
440    pub fn is_estimate(&self) -> bool {
441        self.source == TokenUsageSource::Estimate
442    }
443
444    /// Return a copy with `extra` tokens folded into the running total. Used by
445    /// `/context` to add built-in tool-schema tokens that the reducer's
446    /// MCP-only request estimate can't see. Recomputes `remaining_tokens` and
447    /// `used_percent`; the breakdown is left untouched (the caller surfaces the
448    /// built-in figure on its own line so the MCP line stays accurate).
449    pub fn with_additional_tokens(mut self, extra: usize) -> Self {
450        if extra == 0 {
451            return self;
452        }
453        self.used_tokens = self.used_tokens.saturating_add(extra);
454        self.remaining_tokens = self
455            .max_tokens
456            .map(|max| max.saturating_sub(self.used_tokens));
457        self.used_percent = self
458            .max_tokens
459            .filter(|max| *max > 0)
460            .map(|max| ((self.used_tokens.saturating_mul(100)) / max).min(100) as u8);
461        self
462    }
463}
464
465pub fn estimate_context_usage_for_request(
466    request: &ChatRequest,
467    max_tokens: Option<usize>,
468) -> ContextUsageSnapshot {
469    let system_tokens = approx_tokens(&request.system_prompt);
470    let instructions_tokens = request
471        .instructions
472        .as_deref()
473        .map(approx_tokens)
474        .unwrap_or(0);
475    let message_tokens = request
476        .messages
477        .iter()
478        .map(|msg| {
479            let image_chars = msg
480                .images
481                .as_ref()
482                .map(|imgs| imgs.iter().map(|img| img.len()).sum::<usize>())
483                .unwrap_or(0);
484            // Include assistant tool-call name + arguments JSON, which the
485            // estimate previously ignored (see estimate_message_tokens).
486            let tool_call_chars = msg
487                .tool_calls
488                .as_ref()
489                .map(|calls| {
490                    calls
491                        .iter()
492                        .map(|tc| {
493                            tc.function.name.len()
494                                + tc.function.arguments.to_string().len()
495                                + tc.id.as_deref().map(str::len).unwrap_or(0)
496                        })
497                        .sum::<usize>()
498                })
499                .unwrap_or(0);
500            approx_tokens(&msg.content)
501                .saturating_add(approx_tokens(&format!(
502                    "{:?}{}{}",
503                    msg.role,
504                    msg.tool_name.as_deref().unwrap_or(""),
505                    msg.tool_call_id.as_deref().unwrap_or("")
506                )))
507                .saturating_add(image_chars.div_ceil(4))
508                .saturating_add(tool_call_chars.div_ceil(4))
509        })
510        .sum();
511    let tool_schema_tokens = estimate_tool_schema_tokens(&request.tools);
512    let image_count = request
513        .messages
514        .iter()
515        .filter_map(|msg| msg.images.as_ref())
516        .map(Vec::len)
517        .sum();
518    ContextUsageSnapshot::from_estimate(
519        PromptTokenBreakdown {
520            system_tokens,
521            instructions_tokens,
522            message_tokens,
523            tool_schema_tokens,
524            image_count,
525            message_count: request.messages.len(),
526            tool_count: request.tools.len(),
527        },
528        max_tokens,
529    )
530}
531
532fn approx_tokens(text: &str) -> usize {
533    text.len().div_ceil(4)
534}
535
536/// Estimate the token cost of a set of tool schemas as the model sees them
537/// (serialized OpenAI-style). Shared by `estimate_context_usage_for_request`
538/// and the effect runner so the reducer's `/context` preview can account for
539/// the built-in tool schemas that are only appended to the request during
540/// dispatch enrichment.
541pub fn estimate_tool_schema_tokens(tools: &[super::cmd::ToolDefinition]) -> usize {
542    let tool_schema: Vec<_> = tools.iter().map(|tool| tool.to_openai_json()).collect();
543    serde_json::to_string(&tool_schema)
544        .map(|s| approx_tokens(&s))
545        .unwrap_or(0)
546}
547
548/// Live plan-mode state: present iff the session is currently drafting a
549/// plan. Plan mode is deliberately NOT a fifth `SafetyMode` — it *remembers
550/// and restores* the mode the user was in, which a flat cycle can't express.
551/// While this is `Some`, tool dispatch floors the effective safety mode to
552/// `ReadOnly` and the policy gate applies the plan carve-outs (the plan file
553/// itself, memory writes, known-safe builds).
554///
555/// `Session.safety_mode` is left untouched while planning — it IS the restore
556/// target (the status bar shows it as "restores: <mode>", and Shift+Tab /
557/// `/safety` may retune it mid-plan); only the *effective* mode at tool
558/// dispatch changes.
559///
560/// Serialized into `ConversationHistory` on every save (like `safety_mode`)
561/// so `--resume` restores planning-in-progress; sessions saved before this
562/// field existed deserialize to `None`.
563#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
564pub struct PlanState {
565    /// Absolute path of the plan file the model authors — the single path the
566    /// policy gate exempts from the read-only floor.
567    pub plan_path: std::path::PathBuf,
568    /// Model to restore when plan mode ends. `Some` only when `[plan] model`
569    /// swapped the session onto a plan-phase model at entry.
570    #[serde(default)]
571    pub prev_model_id: Option<String>,
572    /// Reasoning level to restore when plan mode ends. `Some` only when
573    /// `[plan] reasoning` overrode it at entry.
574    #[serde(default)]
575    pub prev_reasoning: Option<crate::models::ReasoningLevel>,
576    /// The safety mode to return to when plan mode ends — captured at entry,
577    /// and re-targeted by Shift+Tab WHILE planning.
578    ///
579    /// `safety_mode` is `Plan` for the duration, so Shift+Tab has to act on
580    /// something else. Staging it here is what makes "pre-set full_access for
581    /// after approval" work without the live mode and the plan floor ever
582    /// disagreeing — the contradiction that used to produce a permanent
583    /// "safety mode changed to full_access" marker while the floor still held.
584    #[serde(default)]
585    pub resume_safety_mode: crate::runtime::SafetyMode,
586}
587
588/// The mode-defining facts the model was last told about, snapshotted at
589/// each dispatch by the context-delta injector
590/// (`reducer::advertise_context_changes`): the reducer diffs live state
591/// against this and injects one persistent history marker per change, then
592/// re-stamps it. One un-bypassable announcement path for plan entry/exit,
593/// safety-mode flips, and model swaps — transitions themselves stay
594/// message-log-free (the codex snapshot+diff pattern).
595///
596/// Lives on `ConversationHistory` (persisted with the transcript) so a
597/// resumed session diffs against what THAT conversation's model last saw,
598/// and `/clear`/fresh forks start from `None` (= seed silently, announce
599/// nothing). Plan permissions stay out: a `/plan config` retune is already
600/// reflected live in the system prompt and never contradicts history.
601#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
602pub struct AdvertisedContext {
603    /// `Some(plan_path)` while the model has been told it is planning.
604    pub plan_path: Option<std::path::PathBuf>,
605    pub safety_mode: SafetyMode,
606    pub model_id: String,
607}
608
609impl AdvertisedContext {
610    /// The facts as they stand right now — the injector's diff input.
611    pub fn observe(session: &Session) -> Self {
612        Self {
613            plan_path: session.plan.as_ref().map(|p| p.plan_path.clone()),
614            safety_mode: session.safety_mode,
615            model_id: session.model_id.clone(),
616        }
617    }
618}
619
620/// Persistent conversational state that survives across turns.
621///
622/// "Session" here means the user-visible chat session, not the tokio
623/// runtime or the TCP connection to the provider. One chat = one
624/// `Session` = one on-disk `ConversationHistory` file.
625#[derive(Debug, Clone)]
626pub struct Session {
627    pub conversation: ConversationHistory,
628    pub model_id: String,
629    pub reasoning: ReasoningLevel,
630    /// Live safety mode for this session. Initialized from
631    /// `config.safety.mode`, then mutated in-session by `Shift+Tab` /
632    /// `/safety` (session-scoped — never written back to the config file).
633    /// The reducer threads this into `Cmd::ExecuteTool` so the policy gate
634    /// enforces the *current* mode, not the startup snapshot.
635    pub safety_mode: SafetyMode,
636    /// Token usage for the most recent completed provider request.
637    /// `None` means the provider did not report usage for that turn.
638    pub last_token_usage: Option<TokenUsageTotals>,
639    /// Prompt/completion/total API usage accumulated for this session.
640    pub cumulative_token_usage: TokenUsageTotals,
641    /// Latest model-visible context snapshot. This may be an estimate
642    /// while a request is in flight and is replaced by provider-reported
643    /// usage when available.
644    pub context_usage: Option<ContextUsageSnapshot>,
645    /// True when this session IS a subagent (a child reducer driven by
646    /// `SubagentTool`). `system_prompt_for_state` appends the subagent
647    /// contract (final message = the report returned to the parent) when
648    /// set. Never true for a user-facing session.
649    pub is_subagent: bool,
650    /// Agent-type system-prompt block (e.g. the Explore type's "read-only
651    /// reconnaissance" charter), appended after the subagent contract.
652    /// Only ever `Some` on subagent sessions.
653    pub agent_preamble: Option<String>,
654    /// `Some` while the session is in plan mode (see [`PlanState`]). Never
655    /// `Some` on subagent sessions — children explore, they don't plan.
656    pub plan: Option<PlanState>,
657    /// Per-session scratch directory, once the effect layer has materialized
658    /// it on disk (`Cmd::EnsureScratchpad` -> `Msg::ScratchpadReady`). `None`
659    /// until then, and reset whenever the conversation id changes (`/clear`,
660    /// `/load`, rewind fork) — the reducer re-emits `EnsureScratchpad` at
661    /// those points. The reducer stamps this onto `Cmd::ExecuteTool` so tools
662    /// see it via `ExecContext::scratchpad`. Runtime-only, never persisted.
663    pub scratchpad: Option<PathBuf>,
664}
665
666impl Session {
667    /// Clone the conversation with the live meters + safety mode overlaid, so
668    /// a saved file carries the full restorable state. These fields live on
669    /// `Session` (which is NOT serialized — only `conversation` is), so every
670    /// `Cmd::SaveConversation` snapshots them in and `seed_conversation`
671    /// hydrates them back on resume.
672    pub fn snapshot_conversation(&self) -> ConversationHistory {
673        let mut history = self.conversation.clone();
674        history.safety_mode = Some(self.safety_mode);
675        history.plan = self.plan.clone();
676        history.last_token_usage = self.last_token_usage;
677        history.cumulative_token_usage = self.cumulative_token_usage;
678        history.context_usage = self.context_usage.clone();
679        history
680    }
681
682    /// The committed message log. All messages visible in the chat
683    /// widget live here; partial in-flight content lives in
684    /// `TurnState::Generating`.
685    pub fn messages(&self) -> &[ChatMessage] {
686        self.conversation.messages()
687    }
688
689    /// Append a committed assistant/user/tool message. Mutation happens
690    /// through here so the reducer has one chokepoint to update the
691    /// conversation's `updated_at` and derived title.
692    ///
693    /// `now` is the reducer's injected clock (`state.now`). It stamps both
694    /// the message's commit timestamp and `updated_at` — the wall-clock
695    /// stamp `ChatMessage::new` put on the message at construction is
696    /// deliberately overwritten with the deterministic one, so `update()`
697    /// is a pure function and `--replay` recommits identical messages.
698    pub fn append(&mut self, mut msg: ChatMessage, now: DateTime<Local>) {
699        msg.timestamp = now;
700        self.conversation.add_messages(&[msg], now);
701    }
702}
703
704/// The turn state machine. Each variant carries its own `TurnId` so
705/// the reducer can cheaply check "is this effect result for the
706/// current turn?" without threading the ID through every match arm.
707///
708/// The `ExecutingTools::outcomes: Vec<Option<ToolOutcome>>` field is
709/// the architectural payoff: every slot starts `None`, flips to
710/// `Some(outcome)` as each tool finishes, and the transition to the
711/// follow-up `Generating` state requires `outcomes` to be fully
712/// populated. Statically impossible to "lose" a tool result.
713#[derive(Debug, Clone)]
714pub enum TurnState {
715    Idle,
716    Generating {
717        id: TurnId,
718        started: SystemTime,
719        partial_text: String,
720        partial_reasoning: String,
721        /// Running token estimate — updated by `StreamText` events.
722        tokens: usize,
723        /// Sub-phase for richer status display (see `GenPhase`).
724        phase: GenPhase,
725        /// Opaque provider state carried until the assistant message commits.
726        provider_continuation: Option<ProviderContinuation>,
727        /// Tool calls the model has streamed so far this turn.
728        /// `StreamToolCall` messages push here; `StreamDone` drains
729        /// the vec, allocates `PendingToolCall` entries, and
730        /// transitions to `ExecutingTools`. When the vec is empty at
731        /// stream end, the turn returns to `Idle`.
732        pending_tool_calls: Vec<ModelToolCall>,
733        /// True when this turn resumes a reply cut by the per-response
734        /// output cap (auto-continue). The commit stamps the resulting
735        /// message `ChatMessageKind::Continuation` so the transcript can
736        /// stitch it into the previous bubble. Survives an intervening
737        /// empty-retry or truncation-recovery compaction so a chain never
738        /// loses the marker mid-way.
739        continuation: bool,
740    },
741    ExecutingTools {
742        id: TurnId,
743        /// When tool execution started, so the status line can show elapsed
744        /// time (a long-running command — `npm run dev`, a slow build — would
745        /// otherwise look frozen at 0s).
746        started: SystemTime,
747        calls: Vec<PendingToolCall>,
748        outcomes: Vec<Option<ToolOutcome>>,
749    },
750    /// Summarizing history as a step of its own: a manual `/compact`
751    /// (`trigger: Manual`, ends the turn afterwards) or a truncation recovery
752    /// (`trigger: TruncationRecovery`, resumes the run afterwards). Pre-turn auto
753    /// compaction instead runs while `Generating` because it is preflight for the
754    /// same user turn. `trigger` is what the finished/failed handlers key off.
755    Compacting {
756        id: TurnId,
757        started: SystemTime,
758        trigger: CompactionTrigger,
759        /// True when the turn that led into this compaction was itself a
760        /// continuation (see `Generating::continuation`): a `TruncationRecovery`
761        /// resume must re-enter `Generating` with the flag intact or a
762        /// continuation chain interrupted by a genuine context-full compaction
763        /// would commit its remaining text unmarked.
764        resume_continuation: bool,
765    },
766    /// `CancelTurn` was dispatched. The reducer has already emitted a
767    /// `Cmd::CancelScope` — now we wait for the final `Cancelled` /
768    /// `StreamDone` that the effect runner sends back when the scope's
769    /// `JoinSet` drains. Only then do we transition to `Idle`.
770    ///
771    /// Stuck in `Cancelling` too long = effect runner has a bug. UI
772    /// surfaces a "cleanup taking a while…" hint after 2s.
773    Cancelling {
774        id: TurnId,
775        since: SystemTime,
776    },
777}
778
779impl TurnState {
780    pub fn id(&self) -> Option<TurnId> {
781        match self {
782            TurnState::Idle => None,
783            TurnState::Generating { id, .. }
784            | TurnState::ExecutingTools { id, .. }
785            | TurnState::Compacting { id, .. }
786            | TurnState::Cancelling { id, .. } => Some(*id),
787        }
788    }
789
790    /// True when a `Msg` tagged with the given `TurnId` should be
791    /// accepted. Events from prior turns return false — the reducer's
792    /// first line on every effect-result arm.
793    pub fn accepts(&self, event_turn: TurnId) -> bool {
794        self.id() == Some(event_turn)
795    }
796}
797
798/// Sub-phase of `Generating`. Informational — the reducer updates it
799/// as the provider's stream progresses so the UI can show a meaningful
800/// status ("Thinking…" vs "Sending…" vs "Streaming").
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum GenPhase {
803    /// Request dispatched, awaiting first byte.
804    Sending,
805    /// First chunk was reasoning content — currently inside a
806    /// thinking/reasoning block.
807    Thinking,
808    /// Streaming assistant content (post-thinking, or no thinking at
809    /// all).
810    Streaming,
811}
812
813/// One pending tool call that the model has asked us to execute. Wraps
814/// the wire-format tool call with an internal ID + the original
815/// provider-native structure so the reducer never loses provenance.
816#[derive(Debug, Clone)]
817pub struct PendingToolCall {
818    pub call_id: ToolCallId,
819    /// The raw tool call as it appeared in the model's response.
820    /// Preserved verbatim so the follow-up tool-result message can
821    /// reference the right function name + id on the wire.
822    pub source: ModelToolCall,
823}
824
825/// Outcome of a single tool execution.
826///
827/// `model_content` is the text that goes back to the model in the
828/// follow-up tool message. Everything else is Mermaid-owned
829/// structure for rendering, replay, process tracking, and timeline
830/// inspection.
831#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
832pub struct ToolOutcome {
833    pub status: ToolStatus,
834    pub summary: String,
835    pub model_content: String,
836    pub error: Option<String>,
837    pub metadata: Box<ToolRunMetadata>,
838    pub artifacts: Vec<ToolArtifact>,
839    pub duration_secs: Option<f64>,
840}
841
842impl ToolOutcome {
843    pub fn success(
844        model_content: impl Into<String>,
845        summary: impl Into<String>,
846        duration_secs: f64,
847    ) -> Self {
848        let duration = Some(duration_secs);
849        let metadata = ToolRunMetadata {
850            duration_secs: duration,
851            ..ToolRunMetadata::default()
852        };
853        Self {
854            status: ToolStatus::Success,
855            summary: summary.into(),
856            model_content: model_content.into(),
857            error: None,
858            metadata: Box::new(metadata),
859            artifacts: Vec::new(),
860            duration_secs: duration,
861        }
862    }
863
864    pub fn error(error: impl Into<String>, duration_secs: f64) -> Self {
865        let error = error.into();
866        let duration = Some(duration_secs);
867        Self {
868            status: ToolStatus::Error,
869            summary: error.clone(),
870            model_content: format!("Error: {}", error),
871            error: Some(error),
872            metadata: Box::new(ToolRunMetadata {
873                duration_secs: duration,
874                ..ToolRunMetadata::default()
875            }),
876            artifacts: Vec::new(),
877            duration_secs: duration,
878        }
879    }
880
881    pub fn cancelled() -> Self {
882        Self {
883            status: ToolStatus::Cancelled,
884            summary: "[cancelled]".to_string(),
885            model_content: "[Tool call skipped: the user cancelled before execution]".to_string(),
886            error: None,
887            metadata: Box::new(ToolRunMetadata::default()),
888            artifacts: Vec::new(),
889            duration_secs: None,
890        }
891    }
892
893    pub fn with_metadata(mut self, mut metadata: ToolRunMetadata) -> Self {
894        metadata.duration_secs = self.duration_secs;
895        self.metadata = Box::new(metadata);
896        self
897    }
898
899    pub fn with_artifacts(mut self, artifacts: Vec<ToolArtifact>) -> Self {
900        self.artifacts = artifacts.clone();
901        self.metadata.artifacts = artifacts;
902        self
903    }
904
905    pub fn with_images(self, images: Vec<String>) -> Self {
906        self.with_artifacts(
907            images
908                .into_iter()
909                .map(|data| ToolArtifact::Image { data })
910                .collect(),
911        )
912    }
913
914    /// Override the status after construction. When transitioning to
915    /// `Error`, populate `error` from `model_content` (if not already set)
916    /// so the renderer — `action_display_for`, which falls back to
917    /// `error_message().unwrap_or("[cancelled]")` — surfaces the failure
918    /// instead of mislabeling it as a cancellation. The MCP proxy uses this
919    /// for `isError: true` results (#91): the model still sees the server's
920    /// content verbatim via `model_content`, but the outcome reads as an
921    /// error rather than a success.
922    pub fn with_status(mut self, status: ToolStatus) -> Self {
923        if status == ToolStatus::Error && self.error.is_none() {
924            self.error = Some(self.model_content.clone());
925        }
926        self.status = status;
927        self
928    }
929
930    pub fn was_cancelled(&self) -> bool {
931        self.status == ToolStatus::Cancelled
932    }
933
934    pub fn is_success(&self) -> bool {
935        self.status == ToolStatus::Success
936    }
937
938    pub fn output(&self) -> &str {
939        &self.model_content
940    }
941
942    pub fn error_message(&self) -> Option<&str> {
943        self.error.as_deref()
944    }
945
946    pub fn images(&self) -> Option<Vec<String>> {
947        let images: Vec<String> = self
948            .artifacts
949            .iter()
950            .filter_map(|artifact| match artifact {
951                ToolArtifact::Image { data } => Some(data.clone()),
952                _ => None,
953            })
954            .collect();
955        if images.is_empty() {
956            None
957        } else {
958            Some(images)
959        }
960    }
961
962    /// Convert to a textual representation suitable for embedding in
963    /// the follow-up `tool` role message. Cancellation produces a
964    /// placeholder so the model sees "this was skipped" rather than
965    /// the history becoming malformed.
966    pub fn as_tool_message_content(&self) -> String {
967        self.model_content.clone()
968    }
969}
970
971/// Live activity for one in-flight tool call (today: a subagent child).
972/// `activity` is a short stable label ("read_file…", "thinking"); `tokens`
973/// is the child's cumulative output-token estimate, throttled at the source.
974#[derive(Debug, Clone, Default, PartialEq, Eq)]
975pub struct LiveToolStatus {
976    pub activity: String,
977    pub tokens: usize,
978}
979
980/// One plugin-contributed slash command (a markdown prompt from an enabled
981/// plugin's `manifest.prompts`). Plain data — parsing/IO happens in
982/// `app::plugin_assets`; the reducer only expands and submits.
983#[derive(Debug, Clone, PartialEq, Eq)]
984pub struct PluginCommand {
985    /// Command name without the leading `/` (validated `[a-z0-9-]+`).
986    pub name: String,
987    /// One-line description for the palette and `/help`.
988    pub description: String,
989    /// The prompt body. `$ARGUMENTS` is replaced with the typed args;
990    /// without the token, non-empty args append as a final paragraph.
991    pub body: String,
992    /// Owning plugin name, shown as `(plugin:<name>)` in the palette.
993    pub plugin: String,
994}
995
996impl PluginCommand {
997    /// Expand the body with typed arguments: replace-all of `$ARGUMENTS`
998    /// when the token is present, else append the args as a new paragraph
999    /// when non-empty. Pure.
1000    pub fn expand(&self, args: &str) -> String {
1001        let args = args.trim();
1002        if self.body.contains("$ARGUMENTS") {
1003            return self.body.replace("$ARGUMENTS", args);
1004        }
1005        if args.is_empty() {
1006            self.body.clone()
1007        } else {
1008            format!("{}\n\n{}", self.body, args)
1009        }
1010    }
1011}
1012
1013/// All UI-only state. Things in `UiState` never affect what gets sent
1014/// to the model — only what the user sees.
1015#[derive(Debug, Clone, Default)]
1016pub struct UiState {
1017    pub mode: UiMode,
1018    /// Active color theme. Seeded from `config.ui.theme` in `State::new`;
1019    /// `/theme` switches it live (and persists via `Cmd::PersistUiTheme`).
1020    /// The render layer memoizes the resolved `Theme` off this value.
1021    pub theme: crate::app::ThemeChoice,
1022    /// `NO_COLOR` was set (present and non-empty) at startup. Injected by the
1023    /// run loop after `State::new` — the reducer never reads the environment.
1024    /// While true the render layer draws `Theme::plain()` regardless of
1025    /// `theme`, and `/theme` notes that colors are disabled.
1026    pub no_color: bool,
1027    pub input_buffer: String,
1028    /// Byte position within `input_buffer`. The reducer normalizes to
1029    /// a UTF-8 char boundary on every mutation via
1030    /// `floor_char_boundary`, so widgets can slice safely.
1031    pub input_cursor: usize,
1032    /// Pending image pastes for the next user message. Each is mirrored by an
1033    /// inline `[Image #N]` token in `input_buffer`; the token is the source of
1034    /// truth at submit time (see `image_token` + `handle_submit_prompt`).
1035    pub attachments: Vec<Attachment>,
1036    /// In-flight `Cmd::ReadClipboard` reads (Ctrl+V) whose result
1037    /// (`Msg::ClipboardRead`) hasn't arrived yet. A counter, not a bool, so a
1038    /// burst of rapid Ctrl+V presses all drain before a held submit fires.
1039    /// Incremented where `Cmd::ReadClipboard` is pushed; decremented in
1040    /// `handle_clipboard_read`.
1041    pub clipboard_reads_pending: u32,
1042    /// Set when Enter is pressed while `clipboard_reads_pending > 0`: the submit
1043    /// is held until the read drains so a fast paste-then-Enter still includes
1044    /// the pasted image instead of racing past it. `handle_clipboard_read`
1045    /// re-runs the submit once the last pending read lands.
1046    pub submit_after_clipboard: bool,
1047    /// When `Some(i)`, the palette has a highlighted row. `None` =
1048    /// closed / not showing.
1049    pub palette_cursor: Option<usize>,
1050    /// Cached project file list for the @-mention picker (relative paths,
1051    /// dirs with a trailing `/`). `None` until the first walk completes;
1052    /// stale-while-revalidate — every picker OPEN refreshes it.
1053    pub project_files: Option<Vec<String>>,
1054    /// A `Cmd::ListProjectFiles` walk is in flight (dedupe: opening the
1055    /// picker again while loading must not spawn a second walk).
1056    pub project_files_loading: bool,
1057    /// Current fuzzy matches for the active @-token, best first (top 50).
1058    /// Recomputed in the reducer on every text mutation — not per-frame in
1059    /// render — because fuzzy-ranking 20k paths at 60 Hz would be wasteful.
1060    pub file_picker_matches: Vec<String>,
1061    /// Highlighted row in `file_picker_matches`. `None` = picker closed.
1062    pub file_picker_cursor: Option<usize>,
1063    /// The user Esc'd the picker for the CURRENT token; cleared on the next
1064    /// text mutation so typing reopens it.
1065    pub file_picker_dismissed: bool,
1066    /// Messages the user typed while a turn was in flight, FIFO. Mid-run
1067    /// steering drains the WHOLE queue at each tool boundary (committed as
1068    /// user messages before the follow-up model call); a message queued
1069    /// mid-stream with no later tool boundary drains one-at-a-time at turn
1070    /// end instead. Each entry carries the attachment ids that were present
1071    /// when the user submitted it, so delivery sends the images that
1072    /// belonged to *that* message.
1073    pub queued_messages: VecDeque<QueuedMessage>,
1074    /// Last terminal title dispatched via `Cmd::SetTerminalTitle`.
1075    /// Arms that change `session.conversation.title` consult this
1076    /// and emit a fresh `SetTerminalTitle` only on diff.
1077    pub last_title_dispatched: Option<String>,
1078    /// Follow-up `Msg`s the reducer has queued for re-entry. The
1079    /// outer `update()` drains this after each single-step call so
1080    /// a handler can emit a synthetic event (e.g. Enter-on-slash
1081    /// queuing `Msg::Slash(cmd)`) without self-invoking the
1082    /// reducer. Bounded drain depth guards against runaway loops.
1083    pub pending_msgs: VecDeque<Msg>,
1084    /// Live activity per in-flight tool call, keyed by the call id.
1085    /// Fed by `Msg::ToolProgress` (today: subagent activity — the child's
1086    /// current tool / coarse phase plus a throttled token count) and rendered
1087    /// by the agent panel + status line next to the tool label. Entries are
1088    /// removed on that call's `ToolFinished` and the map is cleared when the
1089    /// turn ends or cancels; call ids are session-unique, so a stale entry
1090    /// can never attach to a later call.
1091    pub live_tool_status: HashMap<ToolCallId, LiveToolStatus>,
1092    /// Up-arrow history navigation cursor into
1093    /// `session.conversation.input_history`. `None` = not
1094    /// navigating (input_buffer is whatever the user typed).
1095    /// `Some(i)` = currently displaying history entry at index `i`
1096    /// from the END (0 = newest).
1097    pub input_history_cursor: Option<usize>,
1098    /// Whatever the user had typed before hitting Up. Preserved so
1099    /// stepping past the newest history entry with Down restores
1100    /// the partial input unchanged. Cleared on any non-nav key.
1101    pub history_draft: String,
1102    /// Running accumulator for mouse-wheel scroll events (F13). The
1103    /// reducer adds the delta here on `Msg::MouseScroll`; the render
1104    /// layer compares against its last-seen snapshot and applies the
1105    /// diff to the chat pane's `ChatState`. This keeps the reducer
1106    /// pure — it doesn't touch render-layer state, it just publishes
1107    /// an intent. `i32` wraps at ~2 billion scrolls (never).
1108    pub mouse_scroll_accum: i32,
1109    /// Monotonic "jump to bottom" counter (keyboard `End`). Same
1110    /// publish-then-diff pattern as `mouse_scroll_accum`: the reducer bumps it,
1111    /// the render layer diffs it against its last-seen value and calls
1112    /// `ChatState::resume_auto_scroll` — keeping the reducer pure.
1113    pub scroll_to_bottom_seq: u32,
1114    /// Monotonic "repaint everything" counter. Same publish-then-diff pattern
1115    /// as `scroll_to_bottom_seq`: the reducer bumps it, the run loop diffs it
1116    /// against its last-seen value and calls `Terminal::clear()` before the
1117    /// next draw. Needed because ratatui diff-renders against its back buffer:
1118    /// bytes some OTHER process wrote to the tty (a child that opened
1119    /// `/dev/tty`, a stray `printf` from another terminal) are invisible to
1120    /// that buffer and would otherwise persist as ghost cells. Bumped when a
1121    /// shell command finishes and on Ctrl+L.
1122    pub full_redraw_seq: u32,
1123    /// Ctrl+C exit arming (press-twice-to-exit). `Some(deadline)` after a
1124    /// first Ctrl+C: a second press at or before the deadline exits; any
1125    /// other key disarms; past the deadline the next Ctrl+C re-arms. Expiry
1126    /// is lazy — compared against `state.now`, so the render hint vanishes on
1127    /// the next tick with no state change. Ctrl+D on empty input and `/quit`
1128    /// still exit immediately.
1129    pub exit_armed_until: Option<DateTime<Local>>,
1130    /// Double-Esc rewind arming. `Some(t)` after an idle Esc; a second Esc
1131    /// within `ESC_REWIND_WINDOW_MS` of `t` opens the rewind picker. Any
1132    /// other key disarms; expiry is lazy against `state.now` like
1133    /// `exit_armed_until` (the hint vanishes on the next tick). Busy Esc
1134    /// never arms — it stays the cancel gesture.
1135    pub esc_armed_at: Option<DateTime<Local>>,
1136    /// Whether the terminal window has LOST focus (from terminal focus
1137    /// reporting via `Msg::FocusChanged`). Defaults `false` (assume attended, so
1138    /// terminals without focus reporting never ding); the attention bell fires
1139    /// only while this is `true`.
1140    pub terminal_unfocused: bool,
1141    /// Whether committed reasoning/thinking blocks are expanded in
1142    /// the chat transcript. Hidden by default to keep the TUI focused
1143    /// on user-facing work while retaining provider-required history.
1144    pub show_reasoning: bool,
1145    /// Whether the task checklist under the status line is collapsed to its
1146    /// one-line form (Ctrl+T toggles). Named for the non-default state so
1147    /// `derive(Default)` yields expanded, session-scoped, never persisted.
1148    pub tasks_collapsed: bool,
1149}
1150
1151impl UiState {
1152    /// The @-mention token under the cursor, when the picker may show:
1153    /// not user-dismissed, and not while the buffer is a slash command
1154    /// (the slash palette owns that surface).
1155    pub fn active_file_token(&self) -> Option<crate::domain::file_mention::AtToken> {
1156        if self.file_picker_dismissed || self.input_buffer.starts_with('/') {
1157            return None;
1158        }
1159        crate::domain::file_mention::active_at_token(&self.input_buffer, self.input_cursor)
1160    }
1161
1162    /// Whether the @-mention file picker is currently open.
1163    pub fn file_picker_open(&self) -> bool {
1164        self.active_file_token().is_some()
1165    }
1166}
1167
1168/// Top-level UI mode. Like `TurnState` this is a sum type instead of a
1169/// zoo of independent bools. `EditingInput` is the default.
1170#[derive(Debug, Clone, PartialEq, Eq, Default)]
1171pub enum UiMode {
1172    #[default]
1173    EditingInput,
1174    /// `/load` — list of saved conversations visible. `candidates`
1175    /// holds what the effect handler returned; `cursor` is the
1176    /// highlighted row.
1177    ConversationList {
1178        candidates: Vec<ConversationSummary>,
1179        cursor: usize,
1180    },
1181    /// `/model` — list of available models visible.
1182    ModelList,
1183    /// Double-Esc rewind: pick an earlier user message to fork the session
1184    /// at. Candidates are user-role Normal messages, newest first. Selecting
1185    /// one forks into a NEW session (original preserved, lineage stamped)
1186    /// with the composer pre-filled.
1187    /// The `/plan config` settings picker: per-category permission levels,
1188    /// model/reasoning overrides, approval behavior. `cursor` is the
1189    /// highlighted row.
1190    PlanConfig { cursor: usize },
1191    RewindPicker {
1192        candidates: Vec<RewindCandidate>,
1193        cursor: usize,
1194    },
1195}
1196
1197/// One rewind target: a user message's position in the conversation plus a
1198/// one-line excerpt for the picker row. Never rides in a `Msg` (the whole
1199/// flow is Key-driven), so no serde — record/replay work unchanged.
1200#[derive(Debug, Clone, PartialEq, Eq)]
1201pub struct RewindCandidate {
1202    /// Index into `conversation.messages` of the user message; the fork
1203    /// keeps `messages[..index]` and pre-fills the composer with this one.
1204    pub message_index: usize,
1205    /// First line of the message, clipped for the picker row.
1206    pub excerpt: String,
1207}
1208
1209/// Summary row for the conversation picker. Produced by
1210/// `Cmd::ListConversations` → `Msg::ConversationsListed`.
1211#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1212pub struct ConversationSummary {
1213    pub id: String,
1214    pub title: String,
1215    pub message_count: usize,
1216    pub updated_at: String,
1217}
1218
1219/// One pasted image, ready to send. Kept in the reducer state — not on
1220/// disk — because the image hasn't been confirmed for a message yet.
1221#[derive(Debug, Clone)]
1222pub struct Attachment {
1223    pub id: u64,
1224    /// Global, conversation-wide image number — the `N` shown in the inline
1225    /// `[Image #N]` token and, once sent, in the committed message. Stable for
1226    /// the life of the image; distinct from `id`, which only scopes attachment
1227    /// ownership within a submit.
1228    pub number: u64,
1229    pub base64_data: String,
1230    /// Temp file path (written by the effect runner when the paste
1231    /// event comes in, so the TUI can show a preview).
1232    pub temp_path: PathBuf,
1233    pub size_bytes: usize,
1234    pub format: String,
1235}
1236
1237/// A user message queued while a turn was in flight, with the attachment
1238/// ids that were present at submit time. Capturing the ids here (instead
1239/// of re-reading live `ui.attachments` at drain time) ensures the
1240/// auto-submit consumes the images the user attached to *this* message.
1241#[derive(Debug, Clone)]
1242pub struct QueuedMessage {
1243    pub text: String,
1244    pub attachment_ids: Vec<u64>,
1245}
1246
1247/// MCP server lifecycle state. Mutation is driven by `Msg::McpServer*`
1248/// events emitted from `effect::mcp` when a server starts, advertises
1249/// tools, or exits.
1250#[derive(Debug, Clone, Default)]
1251pub struct McpState {
1252    pub servers: HashMap<String, McpServerEntry>,
1253    /// Deferred MCP tools promoted to direct advertisement by a
1254    /// `tool_search` call this session (sanitized full names). A
1255    /// `BTreeSet` keeps the advertised tool order byte-stable across
1256    /// requests for prompt-cache warmth (#F68). Transient: cleared by
1257    /// conversation switch/`/clear` along with the rest of the session.
1258    pub promoted: std::collections::BTreeSet<String>,
1259}
1260
1261#[derive(Debug, Clone)]
1262pub struct McpServerEntry {
1263    pub config: McpServerConfig,
1264    pub status: McpServerStatus,
1265    /// Tools advertised by the server. Populated on the
1266    /// `McpServerReady` event; reducer exposes these to the model
1267    /// when building the tool list for the next request.
1268    pub tools: Vec<McpToolSpec>,
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq)]
1272pub enum McpServerStatus {
1273    /// `initialize` request dispatched, not yet acknowledged.
1274    Starting,
1275    Ready,
1276    Errored {
1277        reason: String,
1278    },
1279    Stopped,
1280}
1281
1282/// Subset of the MCP `ToolDefinition` carried in reducer state. `name` is
1283/// the FULL sanitized advertised name (`mcp__<server>__<tool>`, provider-safe
1284/// charset and length — see `crate::mcp::sanitize`); `raw_name` is the bare
1285/// tool name exactly as the server advertised it, used for user-facing
1286/// display and for `enabled_tools`/`disabled_tools` filtering.
1287#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1288pub struct McpToolSpec {
1289    pub name: String,
1290    /// Bare tool name as the server advertised it (pre-sanitization).
1291    #[serde(default)]
1292    pub raw_name: String,
1293    pub description: String,
1294    pub input_schema: serde_json::Value,
1295    /// Server-advertised `annotations.readOnlyHint` (UNTRUSTED; absent ⇒
1296    /// false = write-shaped). Feeds the external-writes policy floor: it can
1297    /// only keep a read at its old permissiveness, never grant more than the
1298    /// safety mode gives.
1299    #[serde(default)]
1300    pub read_only_hint: bool,
1301}
1302
1303/// A pending user confirmation (modal). Examples: confirming `/clear`,
1304/// confirming overwrite of an existing file on `/save <name>`.
1305#[derive(Debug, Clone)]
1306pub struct Confirmation {
1307    pub prompt: String,
1308    pub accept_msg_token: ConfirmationTarget,
1309}
1310
1311/// What to do when the user confirms. The reducer translates
1312/// `Msg::ConfirmAccepted` into a secondary dispatch based on this.
1313#[derive(Debug, Clone)]
1314pub enum ConfirmationTarget {
1315    ClearConversation,
1316}
1317
1318/// One tool action awaiting inline approval. Built by the policy gate and
1319/// delivered via `Msg::ApprovalRequested`; rendered as a modal. The `prompt`
1320/// body is pre-formatted by the gate (command / path / summary, plus any
1321/// Auto-review reason) so the render layer stays dumb.
1322#[derive(Debug, Clone)]
1323pub struct PendingApproval {
1324    pub turn: TurnId,
1325    pub call_id: ToolCallId,
1326    pub tool: String,
1327    /// `RiskClass::as_str()` — shown on the title line.
1328    pub risk: String,
1329    pub kind: ApprovalKind,
1330    /// Pre-formatted body (the command/path being run + any classifier reason).
1331    pub prompt: String,
1332    /// What "don't ask again" (option 2) will allowlist, shown in the prompt.
1333    pub allowlist_scope: String,
1334    /// Highlighted option for arrow-key navigation: 0 = Yes, 1 = Yes-always,
1335    /// 2 = No. Number keys (1/2/3) still resolve directly regardless of this.
1336    pub selected_option: usize,
1337}
1338
1339/// The user's answer to an approval prompt.
1340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1341pub enum ApprovalChoice {
1342    Approve,
1343    ApproveAlways,
1344    Deny,
1345}
1346
1347/// Category of the gated action — drives the prompt's label. Mirrors the
1348/// runtime `ToolCategory` but lives in `domain` so the pure reducer needn't
1349/// depend on `providers`.
1350#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1351pub enum ApprovalKind {
1352    Shell,
1353    FileMutation,
1354    Web,
1355    Mcp,
1356    Subagent,
1357    ComputerUse,
1358    Classify,
1359}
1360
1361/// Severity carried on `Msg::CompactionFailed`. The compaction-failed handler
1362/// uses it to distinguish a benign no-op (`Info`, e.g. too little history to
1363/// compact) from a real failure worth surfacing.
1364#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1365pub enum StatusKind {
1366    Info,
1367    Warn,
1368    Error,
1369}
1370
1371/// All ID allocators for the session. Grouped so the reducer can
1372/// request any of them through a single `&mut state.ids`.
1373#[derive(Debug, Clone, Copy, Default)]
1374pub struct IdAllocatorBundle {
1375    pub turn: IdAllocator,
1376    pub tool_call: IdAllocator,
1377    /// Global, conversation-wide image counter. Every pasted image draws its
1378    /// stable `[Image #N]` display number from here, so the number stays with
1379    /// that image across the whole chat (and across `--resume`, which reseeds it
1380    /// past the highest persisted number in `seed_conversation`).
1381    pub image: IdAllocator,
1382}
1383
1384impl IdAllocatorBundle {
1385    pub fn fresh_turn(&mut self) -> TurnId {
1386        TurnId(self.turn.next())
1387    }
1388
1389    pub fn fresh_tool_call(&mut self) -> ToolCallId {
1390        ToolCallId(self.tool_call.next())
1391    }
1392
1393    pub fn fresh_image(&mut self) -> u64 {
1394        self.image.next()
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401
1402    fn mock_state() -> State {
1403        State::new(
1404            Config::default(),
1405            PathBuf::from("/tmp/project"),
1406            "ollama/test".to_string(),
1407            chrono::Local::now(),
1408        )
1409    }
1410
1411    #[test]
1412    fn fresh_state_is_idle() {
1413        let s = mock_state();
1414        assert!(matches!(s.turn, TurnState::Idle));
1415        assert!(!s.is_busy());
1416        assert!(s.current_turn_id().is_none());
1417    }
1418
1419    #[test]
1420    fn snapshot_and_seed_round_trip_restores_meters_and_safety() {
1421        // Move the live session state away from its `State::new` defaults, then
1422        // snapshot it into a conversation and seed it back into a fresh state —
1423        // this is exactly the save→resume path.
1424        let mut src = mock_state();
1425        src.session.safety_mode = SafetyMode::FullAccess;
1426        src.session.cumulative_token_usage = TokenUsageTotals {
1427            prompt_tokens: 4321,
1428            ..Default::default()
1429        };
1430        src.session.last_token_usage = Some(TokenUsageTotals {
1431            prompt_tokens: 100,
1432            ..Default::default()
1433        });
1434        src.session.context_usage = Some(ContextUsageSnapshot::new(
1435            8000,
1436            Some(128_000),
1437            TokenUsageSource::Estimate,
1438            8000,
1439            0,
1440            0,
1441            0,
1442            0,
1443            None,
1444        ));
1445
1446        let snapshot = src.session.snapshot_conversation();
1447
1448        let mut restored = mock_state();
1449        assert_eq!(
1450            restored.session.safety_mode,
1451            SafetyMode::Ask,
1452            "config default"
1453        );
1454        assert_eq!(restored.session.cumulative_token_usage.total_tokens(), 0);
1455
1456        restored.seed_conversation(snapshot);
1457        assert_eq!(restored.session.safety_mode, SafetyMode::FullAccess);
1458        assert_eq!(restored.session.cumulative_token_usage.total_tokens(), 4321);
1459        assert_eq!(
1460            restored.session.last_token_usage.unwrap().total_tokens(),
1461            100
1462        );
1463        assert_eq!(restored.session.context_usage.unwrap().used_tokens, 8000);
1464    }
1465
1466    #[test]
1467    fn seed_from_pre_persistence_file_keeps_config_default_safety() {
1468        // A conversation saved before these fields existed has `safety_mode:
1469        // None`; seeding it must NOT clobber the config-default mode that
1470        // `State::new` already set.
1471        let history = ConversationHistory::new(
1472            "/tmp/p".to_string(),
1473            "ollama/test".to_string(),
1474            chrono::Local::now(),
1475        );
1476        assert_eq!(history.safety_mode, None);
1477        let mut restored = mock_state();
1478        restored.session.safety_mode = SafetyMode::Auto; // stand in for a config default
1479        restored.seed_conversation(history);
1480        assert_eq!(
1481            restored.session.safety_mode,
1482            SafetyMode::Auto,
1483            "a None saved mode must not override the config default"
1484        );
1485    }
1486
1487    #[test]
1488    fn turn_state_accepts_matches_id() {
1489        let s = TurnState::Generating {
1490            id: TurnId(7),
1491            started: SystemTime::now(),
1492            partial_text: String::new(),
1493            partial_reasoning: String::new(),
1494            tokens: 0,
1495            phase: GenPhase::Sending,
1496            provider_continuation: None,
1497            pending_tool_calls: Vec::new(),
1498            continuation: false,
1499        };
1500        assert!(s.accepts(TurnId(7)));
1501        assert!(!s.accepts(TurnId(6)));
1502        assert!(!s.accepts(TurnId(8)));
1503    }
1504
1505    #[test]
1506    fn idle_rejects_all_turn_ids() {
1507        let s = TurnState::Idle;
1508        assert!(!s.accepts(TurnId(1)));
1509        assert!(!s.accepts(TurnId(999)));
1510    }
1511
1512    #[test]
1513    fn fresh_id_allocators_monotonic() {
1514        let mut bundle = IdAllocatorBundle::default();
1515        assert_eq!(bundle.fresh_turn(), TurnId(1));
1516        assert_eq!(bundle.fresh_turn(), TurnId(2));
1517        assert_eq!(bundle.fresh_tool_call(), ToolCallId(1));
1518        // Cross-allocator independence — fresh turns don't consume
1519        // tool call IDs.
1520    }
1521
1522    #[test]
1523    fn tool_outcome_cancelled_content_is_placeholder() {
1524        let o = ToolOutcome::cancelled();
1525        assert!(o.was_cancelled());
1526        let content = o.as_tool_message_content();
1527        assert!(content.contains("cancelled"));
1528    }
1529
1530    #[test]
1531    fn tool_outcome_finished_returns_output_verbatim() {
1532        let o = ToolOutcome::success("hello world", "hello world", 0.1);
1533        assert_eq!(o.as_tool_message_content(), "hello world");
1534        assert!(!o.was_cancelled());
1535    }
1536
1537    #[test]
1538    fn session_append_records_message() {
1539        let mut s = mock_state();
1540        s.session.append(ChatMessage::user("hi"), s.now);
1541        assert_eq!(s.session.messages().len(), 1);
1542        assert_eq!(s.session.messages()[0].content, "hi");
1543    }
1544}