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::{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::runtime::{RuntimeState, ToolArtifact, ToolRunMetadata, ToolStatus};
37
38/// Root state. The reducer takes `State` by value, returns a new
39/// `State`, and emits any side-effects as a `Vec<Cmd>`. No `&mut` — a
40/// deliberate choice so tests can diff before/after without aliasing
41/// worries, and so replay ("compute the final State that this Msg log
42/// would produce") is a straight fold.
43#[derive(Debug, Clone)]
44pub struct State {
45    pub session: Session,
46    pub turn: TurnState,
47    pub ui: UiState,
48    pub mcp: McpState,
49    pub settings: Config,
50    pub instructions: Option<LoadedInstructions>,
51    /// Durable semantic memory snapshot (auto-derived index + entries),
52    /// refreshed per turn like `instructions`. Its index is injected into the
53    /// model prompt alongside project instructions.
54    pub memory: Option<crate::app::memory::LoadedMemory>,
55    /// Current working directory. Captured once at startup; tools
56    /// receive it via `ExecContext::workdir` and spawned subprocesses
57    /// inherit it. Centralized here so tests can inject a fake cwd.
58    pub cwd: PathBuf,
59    /// System temp dir, captured once at startup (`std::env::temp_dir()`).
60    /// Pasted-image attachments build their scratch path from it; holding it
61    /// here keeps the reducer free of the env read it used to do inline (#54).
62    pub temp_dir: PathBuf,
63    pub ids: IdAllocatorBundle,
64    /// When `Some`, the next render should pop up a modal confirmation
65    /// (e.g. "are you sure you want to /clear?"). Cleared by the
66    /// reducer when the user answers.
67    pub confirm: Option<Confirmation>,
68    /// FIFO queue of tool actions awaiting the user's inline approval
69    /// (interactive `ask` mode + Auto-mode escalations). The front item is
70    /// rendered as a modal; answering it pops the item and emits
71    /// `Cmd::ResolveApproval`, which unblocks the parked tool task. Empty in
72    /// headless mode (no broker → the out-of-band `/approve` flow instead).
73    pub pending_approval: VecDeque<PendingApproval>,
74    /// Transient status line under the input box. One-shot — cleared by
75    /// `Msg::StatusConsumed` or by the next rendered frame depending on
76    /// `StatusKind`.
77    pub status: Option<StatusLine>,
78    /// Runtime-only observability state: process registry, provider
79    /// capability snapshot, and lifecycle timeline. Not sent to the
80    /// model.
81    pub runtime: RuntimeState,
82    /// Quit flag. When set, the main loop drains pending effects and
83    /// exits. The reducer never panics on its own; it sets this instead.
84    pub should_exit: bool,
85    /// Wall-clock for the current reducer step, injected as data (Cause 3).
86    /// The driver stamps this once per tick — `Local::now()` live, or the
87    /// recorded entry's `ts` on replay — *before* calling `update`. The
88    /// reducer and the `transition` helpers read `state.now` instead of
89    /// `Local::now()` / `SystemTime::now()`, so `update(State, Msg)` is a pure
90    /// function of its inputs: the same `(State, Msg)` always yields the same
91    /// `State`, and folding a recorded `Msg` log recomputes State exactly.
92    pub now: DateTime<Local>,
93}
94
95impl State {
96    /// Build a fresh state tied to a specific model + project dir.
97    /// Nothing about this touches the filesystem or tokio — pure.
98    pub fn new(settings: Config, cwd: PathBuf, model_id: String) -> Self {
99        let project_path = cwd.display().to_string();
100        let conversation = ConversationHistory::new(project_path, model_id.clone());
101        let initial_title = conversation.title.clone();
102        // F5: seed `mcp.servers` from the user's configured MCP
103        // servers with `Starting` status. Previously the map started
104        // empty, and `McpServerReady` handlers used `get_mut` —
105        // configured servers never populated, so their tools never
106        // reached `build_chat_request`'s outgoing tool list.
107        let mcp = {
108            let mut m = McpState::default();
109            for (name, cfg) in &settings.mcp_servers {
110                m.servers.insert(
111                    name.clone(),
112                    McpServerEntry {
113                        config: cfg.clone(),
114                        status: McpServerStatus::Starting,
115                        tools: Vec::new(),
116                    },
117                );
118            }
119            m
120        };
121        // F11: honor the per-model reasoning preference (persisted via
122        // `/reasoning high` while using a specific model). Falls back to
123        // the global default when no entry exists.
124        let reasoning = settings
125            .reasoning_per_model
126            .get(&model_id)
127            .copied()
128            .unwrap_or(settings.default_model.reasoning);
129        let runtime = RuntimeState::new(&model_id);
130        Self {
131            session: Session {
132                conversation,
133                model_id,
134                reasoning,
135                safety_mode: settings.safety.mode,
136                cumulative_tokens: 0,
137                last_token_usage: None,
138                cumulative_token_usage: TokenUsageTotals::default(),
139                context_usage: None,
140            },
141            turn: TurnState::Idle,
142            ui: UiState {
143                last_title_dispatched: Some(initial_title),
144                ..UiState::default()
145            },
146            mcp,
147            settings,
148            instructions: None,
149            memory: None,
150            cwd,
151            temp_dir: std::env::temp_dir(),
152            ids: IdAllocatorBundle::default(),
153            confirm: None,
154            pending_approval: VecDeque::new(),
155            status: None,
156            runtime,
157            should_exit: false,
158            // Seed the injected clock so a freshly-built State is usable before
159            // the driver's first per-tick stamp. The driver overwrites this on
160            // every iteration (Cause 3); the reducer never reads the wall clock
161            // directly.
162            now: Local::now(),
163        }
164    }
165
166    /// True iff the reducer is currently mid-turn. UI uses this for
167    /// the "⏎ cancels generation" hint and for keybind routing.
168    pub fn is_busy(&self) -> bool {
169        !matches!(self.turn, TurnState::Idle)
170    }
171
172    /// The active `TurnId`, if any turn is in flight. The reducer
173    /// filters incoming effect messages by comparing their embedded
174    /// `TurnId` to this value — if the user cancelled and started a
175    /// new turn, stale results from the old turn are dropped cleanly.
176    pub fn current_turn_id(&self) -> Option<TurnId> {
177        self.turn.id()
178    }
179}
180
181/// Prompt/completion/total token counts normalized for UI display.
182/// Providers report usage per API request; the session keeps both the
183/// last request and the cumulative API usage so the footer does not
184/// imply this is the current model context length.
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
186pub struct TokenUsageTotals {
187    pub prompt_tokens: usize,
188    pub completion_tokens: usize,
189    pub total_tokens: usize,
190    pub cached_input_tokens: usize,
191    pub cache_creation_input_tokens: usize,
192    pub reasoning_output_tokens: usize,
193}
194
195impl TokenUsageTotals {
196    pub fn from_usage(usage: &TokenUsage) -> Self {
197        Self {
198            prompt_tokens: usage.prompt_tokens,
199            completion_tokens: usage.completion_tokens,
200            total_tokens: usage.total_tokens,
201            cached_input_tokens: usage.cached_input_tokens,
202            cache_creation_input_tokens: usage.cache_creation_input_tokens,
203            reasoning_output_tokens: usage.reasoning_output_tokens,
204        }
205    }
206
207    pub fn add_assign(&mut self, other: Self) {
208        self.prompt_tokens = self.prompt_tokens.saturating_add(other.prompt_tokens);
209        self.completion_tokens = self
210            .completion_tokens
211            .saturating_add(other.completion_tokens);
212        self.total_tokens = self.total_tokens.saturating_add(other.total_tokens);
213        self.cached_input_tokens = self
214            .cached_input_tokens
215            .saturating_add(other.cached_input_tokens);
216        self.cache_creation_input_tokens = self
217            .cache_creation_input_tokens
218            .saturating_add(other.cache_creation_input_tokens);
219        self.reasoning_output_tokens = self
220            .reasoning_output_tokens
221            .saturating_add(other.reasoning_output_tokens);
222    }
223
224    pub fn input_total_tokens(&self) -> usize {
225        self.prompt_tokens
226            .saturating_add(self.cached_input_tokens)
227            .saturating_add(self.cache_creation_input_tokens)
228    }
229
230    pub fn output_total_tokens(&self) -> usize {
231        self.completion_tokens
232            .saturating_add(self.reasoning_output_tokens)
233    }
234}
235
236/// Approximate request-context breakdown used before provider usage
237/// arrives. These numbers are diagnostic estimates, not billing facts.
238#[derive(Debug, Clone, Default, PartialEq, Eq)]
239pub struct PromptTokenBreakdown {
240    pub system_tokens: usize,
241    pub instructions_tokens: usize,
242    pub message_tokens: usize,
243    pub tool_schema_tokens: usize,
244    pub image_count: usize,
245    pub message_count: usize,
246    pub tool_count: usize,
247}
248
249impl PromptTokenBreakdown {
250    pub fn total_tokens(&self) -> usize {
251        self.system_tokens
252            .saturating_add(self.instructions_tokens)
253            .saturating_add(self.message_tokens)
254            .saturating_add(self.tool_schema_tokens)
255    }
256}
257
258/// The model-visible context for the latest request. This is separate
259/// from cumulative session usage, which is an API/accounting total.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct ContextUsageSnapshot {
262    pub used_tokens: usize,
263    pub max_tokens: Option<usize>,
264    pub remaining_tokens: Option<usize>,
265    pub used_percent: Option<u8>,
266    pub source: TokenUsageSource,
267    pub prompt_tokens: usize,
268    pub cached_input_tokens: usize,
269    pub cache_creation_input_tokens: usize,
270    pub completion_tokens: usize,
271    pub reasoning_output_tokens: usize,
272    pub breakdown: Option<PromptTokenBreakdown>,
273}
274
275impl ContextUsageSnapshot {
276    pub fn from_usage(usage: &TokenUsage, max_tokens: Option<usize>) -> Self {
277        Self::new(
278            usage.total_tokens,
279            max_tokens,
280            usage.source,
281            usage.prompt_tokens,
282            usage.cached_input_tokens,
283            usage.cache_creation_input_tokens,
284            usage.completion_tokens,
285            usage.reasoning_output_tokens,
286            None,
287        )
288    }
289
290    pub fn from_estimate(breakdown: PromptTokenBreakdown, max_tokens: Option<usize>) -> Self {
291        let used = breakdown.total_tokens();
292        Self::new(
293            used,
294            max_tokens,
295            TokenUsageSource::Estimate,
296            used,
297            0,
298            0,
299            0,
300            0,
301            Some(breakdown),
302        )
303    }
304
305    #[allow(clippy::too_many_arguments)]
306    fn new(
307        used_tokens: usize,
308        max_tokens: Option<usize>,
309        source: TokenUsageSource,
310        prompt_tokens: usize,
311        cached_input_tokens: usize,
312        cache_creation_input_tokens: usize,
313        completion_tokens: usize,
314        reasoning_output_tokens: usize,
315        breakdown: Option<PromptTokenBreakdown>,
316    ) -> Self {
317        let remaining_tokens = max_tokens.map(|max| max.saturating_sub(used_tokens));
318        let used_percent = max_tokens
319            .filter(|max| *max > 0)
320            .map(|max| ((used_tokens.saturating_mul(100)) / max).min(100) as u8);
321        Self {
322            used_tokens,
323            max_tokens,
324            remaining_tokens,
325            used_percent,
326            source,
327            prompt_tokens,
328            cached_input_tokens,
329            cache_creation_input_tokens,
330            completion_tokens,
331            reasoning_output_tokens,
332            breakdown,
333        }
334    }
335
336    pub fn is_estimate(&self) -> bool {
337        self.source == TokenUsageSource::Estimate
338    }
339
340    /// Return a copy with `extra` tokens folded into the running total. Used by
341    /// `/context` to add built-in tool-schema tokens that the reducer's
342    /// MCP-only request estimate can't see. Recomputes `remaining_tokens` and
343    /// `used_percent`; the breakdown is left untouched (the caller surfaces the
344    /// built-in figure on its own line so the MCP line stays accurate).
345    pub fn with_additional_tokens(mut self, extra: usize) -> Self {
346        if extra == 0 {
347            return self;
348        }
349        self.used_tokens = self.used_tokens.saturating_add(extra);
350        self.remaining_tokens = self
351            .max_tokens
352            .map(|max| max.saturating_sub(self.used_tokens));
353        self.used_percent = self
354            .max_tokens
355            .filter(|max| *max > 0)
356            .map(|max| ((self.used_tokens.saturating_mul(100)) / max).min(100) as u8);
357        self
358    }
359}
360
361pub fn estimate_context_usage_for_request(
362    request: &ChatRequest,
363    max_tokens: Option<usize>,
364) -> ContextUsageSnapshot {
365    let system_tokens = approx_tokens(&request.system_prompt);
366    let instructions_tokens = request
367        .instructions
368        .as_deref()
369        .map(approx_tokens)
370        .unwrap_or(0);
371    let message_tokens = request
372        .messages
373        .iter()
374        .map(|msg| {
375            let image_chars = msg
376                .images
377                .as_ref()
378                .map(|imgs| imgs.iter().map(|img| img.len()).sum::<usize>())
379                .unwrap_or(0);
380            // Include assistant tool-call name + arguments JSON, which the
381            // estimate previously ignored (see estimate_message_tokens).
382            let tool_call_chars = msg
383                .tool_calls
384                .as_ref()
385                .map(|calls| {
386                    calls
387                        .iter()
388                        .map(|tc| {
389                            tc.function.name.len()
390                                + tc.function.arguments.to_string().len()
391                                + tc.id.as_deref().map(str::len).unwrap_or(0)
392                        })
393                        .sum::<usize>()
394                })
395                .unwrap_or(0);
396            approx_tokens(&msg.content)
397                .saturating_add(approx_tokens(&format!(
398                    "{:?}{}{}",
399                    msg.role,
400                    msg.tool_name.as_deref().unwrap_or(""),
401                    msg.tool_call_id.as_deref().unwrap_or("")
402                )))
403                .saturating_add(image_chars.div_ceil(4))
404                .saturating_add(tool_call_chars.div_ceil(4))
405        })
406        .sum();
407    let tool_schema_tokens = estimate_tool_schema_tokens(&request.tools);
408    let image_count = request
409        .messages
410        .iter()
411        .filter_map(|msg| msg.images.as_ref())
412        .map(Vec::len)
413        .sum();
414    ContextUsageSnapshot::from_estimate(
415        PromptTokenBreakdown {
416            system_tokens,
417            instructions_tokens,
418            message_tokens,
419            tool_schema_tokens,
420            image_count,
421            message_count: request.messages.len(),
422            tool_count: request.tools.len(),
423        },
424        max_tokens,
425    )
426}
427
428fn approx_tokens(text: &str) -> usize {
429    text.len().div_ceil(4)
430}
431
432/// Estimate the token cost of a set of tool schemas as the model sees them
433/// (serialized OpenAI-style). Shared by `estimate_context_usage_for_request`
434/// and the effect runner so the reducer's `/context` preview can account for
435/// the built-in tool schemas that are only appended to the request during
436/// dispatch enrichment.
437pub fn estimate_tool_schema_tokens(tools: &[super::cmd::ToolDefinition]) -> usize {
438    let tool_schema: Vec<_> = tools.iter().map(|tool| tool.to_openai_json()).collect();
439    serde_json::to_string(&tool_schema)
440        .map(|s| approx_tokens(&s))
441        .unwrap_or(0)
442}
443
444/// Persistent conversational state that survives across turns.
445///
446/// "Session" here means the user-visible chat session, not the tokio
447/// runtime or the TCP connection to the provider. One chat = one
448/// `Session` = one on-disk `ConversationHistory` file.
449#[derive(Debug, Clone)]
450pub struct Session {
451    pub conversation: ConversationHistory,
452    pub model_id: String,
453    pub reasoning: ReasoningLevel,
454    /// Live safety mode for this session. Initialized from
455    /// `config.safety.mode`, then mutated in-session by `Shift+Tab` /
456    /// `/safety` (session-scoped — never written back to the config file).
457    /// The reducer threads this into `Cmd::ExecuteTool` so the policy gate
458    /// enforces the *current* mode, not the startup snapshot.
459    pub safety_mode: SafetyMode,
460    /// Running total of tokens consumed across every API request in
461    /// this session. Kept for CLI JSON compatibility; the richer
462    /// prompt/completion breakdown lives in `cumulative_token_usage`.
463    pub cumulative_tokens: usize,
464    /// Token usage for the most recent completed provider request.
465    /// `None` means the provider did not report usage for that turn.
466    pub last_token_usage: Option<TokenUsageTotals>,
467    /// Prompt/completion/total API usage accumulated for this session.
468    pub cumulative_token_usage: TokenUsageTotals,
469    /// Latest model-visible context snapshot. This may be an estimate
470    /// while a request is in flight and is replaced by provider-reported
471    /// usage when available.
472    pub context_usage: Option<ContextUsageSnapshot>,
473}
474
475impl Session {
476    /// The committed message log. All messages visible in the chat
477    /// widget live here; partial in-flight content lives in
478    /// `TurnState::Generating`.
479    pub fn messages(&self) -> &[ChatMessage] {
480        &self.conversation.messages
481    }
482
483    /// Append a committed assistant/user/tool message. Mutation happens
484    /// through here so the reducer has one chokepoint to update the
485    /// conversation's `updated_at` and derived title. Pure — no I/O.
486    pub fn append(&mut self, msg: ChatMessage) {
487        self.conversation.add_messages(&[msg]);
488    }
489}
490
491/// The turn state machine. Each variant carries its own `TurnId` so
492/// the reducer can cheaply check "is this effect result for the
493/// current turn?" without threading the ID through every match arm.
494///
495/// The `ExecutingTools::outcomes: Vec<Option<ToolOutcome>>` field is
496/// the architectural payoff: every slot starts `None`, flips to
497/// `Some(outcome)` as each tool finishes, and the transition to the
498/// follow-up `Generating` state requires `outcomes` to be fully
499/// populated. Statically impossible to "lose" a tool result.
500#[derive(Debug, Clone)]
501pub enum TurnState {
502    Idle,
503    Generating {
504        id: TurnId,
505        started: SystemTime,
506        partial_text: String,
507        partial_reasoning: String,
508        /// Running token estimate — updated by `StreamText` events.
509        tokens: usize,
510        /// Sub-phase for richer status display (see `GenPhase`).
511        phase: GenPhase,
512        /// Anthropic-only: carries forward across the turn so we can
513        /// attach it to the committed assistant message. `None` until
514        /// the Anthropic adapter emits a signature event.
515        thinking_signature: Option<String>,
516        /// Tool calls the model has streamed so far this turn.
517        /// `StreamToolCall` messages push here; `StreamDone` drains
518        /// the vec, allocates `PendingToolCall` entries, and
519        /// transitions to `ExecutingTools`. When the vec is empty at
520        /// stream end, the turn returns to `Idle`.
521        pending_tool_calls: Vec<ModelToolCall>,
522    },
523    ExecutingTools {
524        id: TurnId,
525        /// When tool execution started, so the status line can show elapsed
526        /// time (a long-running command — `npm run dev`, a slow build — would
527        /// otherwise look frozen at 0s).
528        started: SystemTime,
529        calls: Vec<PendingToolCall>,
530        outcomes: Vec<Option<ToolOutcome>>,
531    },
532    /// Summarizing history as a step of its own: a manual `/compact`
533    /// (`trigger: Manual`, ends the turn afterwards) or a truncation recovery
534    /// (`trigger: TruncationRecovery`, resumes the run afterwards). Pre-turn auto
535    /// compaction instead runs while `Generating` because it is preflight for the
536    /// same user turn. `trigger` is what the finished/failed handlers key off.
537    Compacting {
538        id: TurnId,
539        started: SystemTime,
540        trigger: CompactionTrigger,
541    },
542    /// `CancelTurn` was dispatched. The reducer has already emitted a
543    /// `Cmd::CancelScope` — now we wait for the final `Cancelled` /
544    /// `StreamDone` that the effect runner sends back when the scope's
545    /// `JoinSet` drains. Only then do we transition to `Idle`.
546    ///
547    /// Stuck in `Cancelling` too long = effect runner has a bug. UI
548    /// surfaces a "cleanup taking a while…" hint after 2s.
549    Cancelling {
550        id: TurnId,
551        since: SystemTime,
552    },
553}
554
555impl TurnState {
556    pub fn id(&self) -> Option<TurnId> {
557        match self {
558            TurnState::Idle => None,
559            TurnState::Generating { id, .. }
560            | TurnState::ExecutingTools { id, .. }
561            | TurnState::Compacting { id, .. }
562            | TurnState::Cancelling { id, .. } => Some(*id),
563        }
564    }
565
566    /// True when a `Msg` tagged with the given `TurnId` should be
567    /// accepted. Events from prior turns return false — the reducer's
568    /// first line on every effect-result arm.
569    pub fn accepts(&self, event_turn: TurnId) -> bool {
570        self.id() == Some(event_turn)
571    }
572}
573
574/// Sub-phase of `Generating`. Informational — the reducer updates it
575/// as the provider's stream progresses so the UI can show a meaningful
576/// status ("Thinking…" vs "Sending…" vs "Streaming").
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum GenPhase {
579    /// Request dispatched, awaiting first byte.
580    Sending,
581    /// First chunk was reasoning content — currently inside a
582    /// thinking/reasoning block.
583    Thinking,
584    /// Streaming assistant content (post-thinking, or no thinking at
585    /// all).
586    Streaming,
587}
588
589/// One pending tool call that the model has asked us to execute. Wraps
590/// the wire-format tool call with an internal ID + the original
591/// provider-native structure so the reducer never loses provenance.
592#[derive(Debug, Clone)]
593pub struct PendingToolCall {
594    pub call_id: ToolCallId,
595    /// The raw tool call as it appeared in the model's response.
596    /// Preserved verbatim so the follow-up tool-result message can
597    /// reference the right function name + id on the wire.
598    pub source: ModelToolCall,
599}
600
601/// Outcome of a single tool execution.
602///
603/// `model_content` is the text that goes back to the model in the
604/// follow-up tool message. Everything else is Mermaid-owned
605/// structure for rendering, replay, process tracking, and timeline
606/// inspection.
607#[derive(Debug, Clone, PartialEq)]
608pub struct ToolOutcome {
609    pub status: ToolStatus,
610    pub summary: String,
611    pub model_content: String,
612    pub error: Option<String>,
613    pub metadata: Box<ToolRunMetadata>,
614    pub artifacts: Vec<ToolArtifact>,
615    pub duration_secs: Option<f64>,
616}
617
618impl ToolOutcome {
619    pub fn success(
620        model_content: impl Into<String>,
621        summary: impl Into<String>,
622        duration_secs: f64,
623    ) -> Self {
624        let duration = Some(duration_secs);
625        let metadata = ToolRunMetadata {
626            duration_secs: duration,
627            ..ToolRunMetadata::default()
628        };
629        Self {
630            status: ToolStatus::Success,
631            summary: summary.into(),
632            model_content: model_content.into(),
633            error: None,
634            metadata: Box::new(metadata),
635            artifacts: Vec::new(),
636            duration_secs: duration,
637        }
638    }
639
640    pub fn error(error: impl Into<String>, duration_secs: f64) -> Self {
641        let error = error.into();
642        let duration = Some(duration_secs);
643        Self {
644            status: ToolStatus::Error,
645            summary: error.clone(),
646            model_content: format!("Error: {}", error),
647            error: Some(error),
648            metadata: Box::new(ToolRunMetadata {
649                duration_secs: duration,
650                ..ToolRunMetadata::default()
651            }),
652            artifacts: Vec::new(),
653            duration_secs: duration,
654        }
655    }
656
657    pub fn cancelled() -> Self {
658        Self {
659            status: ToolStatus::Cancelled,
660            summary: "[cancelled]".to_string(),
661            model_content: "[Tool call skipped: the user cancelled before execution]".to_string(),
662            error: None,
663            metadata: Box::new(ToolRunMetadata::default()),
664            artifacts: Vec::new(),
665            duration_secs: None,
666        }
667    }
668
669    pub fn with_metadata(mut self, mut metadata: ToolRunMetadata) -> Self {
670        metadata.duration_secs = self.duration_secs;
671        self.metadata = Box::new(metadata);
672        self
673    }
674
675    pub fn with_artifacts(mut self, artifacts: Vec<ToolArtifact>) -> Self {
676        self.artifacts = artifacts.clone();
677        self.metadata.artifacts = artifacts;
678        self
679    }
680
681    pub fn with_images(self, images: Vec<String>) -> Self {
682        self.with_artifacts(
683            images
684                .into_iter()
685                .map(|data| ToolArtifact::Image { data })
686                .collect(),
687        )
688    }
689
690    /// Override the status after construction. When transitioning to
691    /// `Error`, populate `error` from `model_content` (if not already set)
692    /// so the renderer — `action_display_for`, which falls back to
693    /// `error_message().unwrap_or("[cancelled]")` — surfaces the failure
694    /// instead of mislabeling it as a cancellation. The MCP proxy uses this
695    /// for `isError: true` results (#91): the model still sees the server's
696    /// content verbatim via `model_content`, but the outcome reads as an
697    /// error rather than a success.
698    pub fn with_status(mut self, status: ToolStatus) -> Self {
699        if status == ToolStatus::Error && self.error.is_none() {
700            self.error = Some(self.model_content.clone());
701        }
702        self.status = status;
703        self
704    }
705
706    pub fn was_cancelled(&self) -> bool {
707        self.status == ToolStatus::Cancelled
708    }
709
710    pub fn is_success(&self) -> bool {
711        self.status == ToolStatus::Success
712    }
713
714    pub fn output(&self) -> &str {
715        &self.model_content
716    }
717
718    pub fn error_message(&self) -> Option<&str> {
719        self.error.as_deref()
720    }
721
722    pub fn images(&self) -> Option<Vec<String>> {
723        let images: Vec<String> = self
724            .artifacts
725            .iter()
726            .filter_map(|artifact| match artifact {
727                ToolArtifact::Image { data } => Some(data.clone()),
728                _ => None,
729            })
730            .collect();
731        if images.is_empty() {
732            None
733        } else {
734            Some(images)
735        }
736    }
737
738    /// Convert to a textual representation suitable for embedding in
739    /// the follow-up `tool` role message. Cancellation produces a
740    /// placeholder so the model sees "this was skipped" rather than
741    /// the history becoming malformed.
742    pub fn as_tool_message_content(&self) -> String {
743        self.model_content.clone()
744    }
745}
746
747/// All UI-only state. Things in `UiState` never affect what gets sent
748/// to the model — only what the user sees.
749#[derive(Debug, Clone, Default)]
750pub struct UiState {
751    pub mode: UiMode,
752    pub input_buffer: String,
753    /// Byte position within `input_buffer`. The reducer normalizes to
754    /// a UTF-8 char boundary on every mutation via
755    /// `floor_char_boundary`, so widgets can slice safely.
756    pub input_cursor: usize,
757    /// Pending image pastes queued for the next user message.
758    pub attachments: Vec<Attachment>,
759    /// When true, keyboard focus is on the attachment bar (up arrow
760    /// from input moves focus up here; Esc returns focus to input).
761    pub attachment_focused: bool,
762    /// Highlighted attachment index when focused. Ignored when
763    /// `attachment_focused` is false.
764    pub attachment_selected: usize,
765    /// Scroll offset for the chat pane.
766    pub chat_scroll: usize,
767    /// When the slash-palette is open, this holds the filter prefix
768    /// (typed after the leading `/`) so the palette widget can
769    /// re-query the registry.
770    pub palette_filter: String,
771    /// When `Some(i)`, the palette has a highlighted row. `None` =
772    /// closed / not showing.
773    pub palette_cursor: Option<usize>,
774    /// Messages the user typed while a turn was in flight. The
775    /// reducer pops the oldest and auto-submits on a successful
776    /// `StreamDone`. FIFO order. Each entry carries the attachment
777    /// ids that were present when the user submitted it, so the
778    /// auto-submit sends the images that belonged to *that* message.
779    pub queued_messages: VecDeque<QueuedMessage>,
780    /// Last terminal title dispatched via `Cmd::SetTerminalTitle`.
781    /// Arms that change `session.conversation.title` consult this
782    /// and emit a fresh `SetTerminalTitle` only on diff.
783    pub last_title_dispatched: Option<String>,
784    /// Follow-up `Msg`s the reducer has queued for re-entry. The
785    /// outer `update()` drains this after each single-step call so
786    /// a handler can emit a synthetic event (e.g. Enter-on-slash
787    /// queuing `Msg::Slash(cmd)`) without self-invoking the
788    /// reducer. Bounded drain depth guards against runaway loops.
789    pub pending_msgs: VecDeque<Msg>,
790    /// Up-arrow history navigation cursor into
791    /// `session.conversation.input_history`. `None` = not
792    /// navigating (input_buffer is whatever the user typed).
793    /// `Some(i)` = currently displaying history entry at index `i`
794    /// from the END (0 = newest).
795    pub input_history_cursor: Option<usize>,
796    /// Whatever the user had typed before hitting Up. Preserved so
797    /// stepping past the newest history entry with Down restores
798    /// the partial input unchanged. Cleared on any non-nav key.
799    pub history_draft: String,
800    /// Running accumulator for mouse-wheel scroll events (F13). The
801    /// reducer adds the delta here on `Msg::MouseScroll`; the render
802    /// layer compares against its last-seen snapshot and applies the
803    /// diff to the chat pane's `ChatState`. This keeps the reducer
804    /// pure — it doesn't touch render-layer state, it just publishes
805    /// an intent. `i32` wraps at ~2 billion scrolls (never).
806    pub mouse_scroll_accum: i32,
807    /// Whether committed reasoning/thinking blocks are expanded in
808    /// the chat transcript. Hidden by default to keep the TUI focused
809    /// on user-facing work while retaining provider-required history.
810    pub show_reasoning: bool,
811}
812
813/// Top-level UI mode. Like `TurnState` this is a sum type instead of a
814/// zoo of independent bools. `EditingInput` is the default.
815#[derive(Debug, Clone, PartialEq, Eq, Default)]
816pub enum UiMode {
817    #[default]
818    EditingInput,
819    /// Slash-command palette open (user typed `/`).
820    Palette,
821    /// `/load` — list of saved conversations visible. `candidates`
822    /// holds what the effect handler returned; `cursor` is the
823    /// highlighted row.
824    ConversationList {
825        candidates: Vec<ConversationSummary>,
826        cursor: usize,
827    },
828    /// `/model` — list of available models visible.
829    ModelList,
830}
831
832/// Summary row for the conversation picker. Produced by
833/// `Cmd::ListConversations` → `Msg::ConversationsListed`.
834#[derive(Debug, Clone, PartialEq, Eq)]
835pub struct ConversationSummary {
836    pub id: String,
837    pub title: String,
838    pub message_count: usize,
839    pub updated_at: String,
840}
841
842/// One pasted image, ready to send. Kept in the reducer state — not on
843/// disk — because the image hasn't been confirmed for a message yet.
844#[derive(Debug, Clone)]
845pub struct Attachment {
846    pub id: u64,
847    pub base64_data: String,
848    /// Temp file path (written by the effect runner when the paste
849    /// event comes in, so the TUI can show a preview).
850    pub temp_path: PathBuf,
851    pub size_bytes: usize,
852    pub format: String,
853}
854
855/// A user message queued while a turn was in flight, with the attachment
856/// ids that were present at submit time. Capturing the ids here (instead
857/// of re-reading live `ui.attachments` at drain time) ensures the
858/// auto-submit consumes the images the user attached to *this* message.
859#[derive(Debug, Clone)]
860pub struct QueuedMessage {
861    pub text: String,
862    pub attachment_ids: Vec<u64>,
863}
864
865/// MCP server lifecycle state. Mutation is driven by `Msg::McpServer*`
866/// events emitted from `effect::mcp` when a server starts, advertises
867/// tools, or exits.
868#[derive(Debug, Clone, Default)]
869pub struct McpState {
870    pub servers: HashMap<String, McpServerEntry>,
871}
872
873#[derive(Debug, Clone)]
874pub struct McpServerEntry {
875    pub config: McpServerConfig,
876    pub status: McpServerStatus,
877    /// Tools advertised by the server. Populated on the
878    /// `McpServerReady` event; reducer exposes these to the model
879    /// when building the tool list for the next request.
880    pub tools: Vec<McpToolSpec>,
881}
882
883#[derive(Debug, Clone, PartialEq, Eq)]
884pub enum McpServerStatus {
885    /// `initialize` request dispatched, not yet acknowledged.
886    Starting,
887    Ready,
888    Errored {
889        reason: String,
890    },
891    Stopped,
892}
893
894/// Subset of the MCP `ToolDefinition` carried in reducer state. The
895/// reducer doesn't need the full schema; the effect layer uses the
896/// server name + tool name to route, and the reducer uses the
897/// description for palette display.
898#[derive(Debug, Clone)]
899pub struct McpToolSpec {
900    pub name: String,
901    pub description: String,
902    pub input_schema: serde_json::Value,
903}
904
905/// A pending user confirmation (modal). Examples: confirming `/clear`,
906/// confirming overwrite of an existing file on `/save <name>`.
907#[derive(Debug, Clone)]
908pub struct Confirmation {
909    pub prompt: String,
910    pub accept_msg_token: ConfirmationTarget,
911}
912
913/// What to do when the user confirms. The reducer translates
914/// `Msg::ConfirmAccepted` into a secondary dispatch based on this.
915#[derive(Debug, Clone)]
916pub enum ConfirmationTarget {
917    ClearConversation,
918}
919
920/// One tool action awaiting inline approval. Built by the policy gate and
921/// delivered via `Msg::ApprovalRequested`; rendered as a modal. The `prompt`
922/// body is pre-formatted by the gate (command / path / summary, plus any
923/// Auto-review reason) so the render layer stays dumb.
924#[derive(Debug, Clone)]
925pub struct PendingApproval {
926    pub turn: TurnId,
927    pub call_id: ToolCallId,
928    pub tool: String,
929    /// `RiskClass::as_str()` — shown on the title line.
930    pub risk: String,
931    pub kind: ApprovalKind,
932    /// Pre-formatted body (the command/path being run + any classifier reason).
933    pub prompt: String,
934    /// What "don't ask again" (option 2) will allowlist, shown in the prompt.
935    pub allowlist_scope: String,
936    /// Highlighted option for arrow-key navigation: 0 = Yes, 1 = Yes-always,
937    /// 2 = No. Number keys (1/2/3) still resolve directly regardless of this.
938    pub selected_option: usize,
939}
940
941/// The user's answer to an approval prompt.
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943pub enum ApprovalChoice {
944    Approve,
945    ApproveAlways,
946    Deny,
947}
948
949/// Category of the gated action — drives the prompt's label. Mirrors the
950/// runtime `ToolCategory` but lives in `domain` so the pure reducer needn't
951/// depend on `providers`.
952#[derive(Debug, Clone, Copy, PartialEq, Eq)]
953pub enum ApprovalKind {
954    Shell,
955    FileMutation,
956    Web,
957    Mcp,
958    Subagent,
959    ComputerUse,
960    Classify,
961}
962
963/// Transient status line shown under the input box. Self-clears after
964/// its kind's expected lifetime — `Persistent` entries stay until
965/// explicitly dismissed.
966#[derive(Debug, Clone)]
967pub struct StatusLine {
968    pub text: String,
969    pub kind: StatusKind,
970    pub shown_at: SystemTime,
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
974pub enum StatusKind {
975    Info,
976    Warn,
977    Error,
978    /// Stays until the next turn or explicit dismissal.
979    Persistent,
980}
981
982/// All ID allocators for the session. Grouped so the reducer can
983/// request any of them through a single `&mut state.ids`.
984#[derive(Debug, Clone, Copy, Default)]
985pub struct IdAllocatorBundle {
986    pub turn: IdAllocator,
987    pub tool_call: IdAllocator,
988}
989
990impl IdAllocatorBundle {
991    pub fn fresh_turn(&mut self) -> TurnId {
992        TurnId(self.turn.next())
993    }
994
995    pub fn fresh_tool_call(&mut self) -> ToolCallId {
996        ToolCallId(self.tool_call.next())
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    fn mock_state() -> State {
1005        State::new(
1006            Config::default(),
1007            PathBuf::from("/tmp/project"),
1008            "ollama/test".to_string(),
1009        )
1010    }
1011
1012    #[test]
1013    fn fresh_state_is_idle() {
1014        let s = mock_state();
1015        assert!(matches!(s.turn, TurnState::Idle));
1016        assert!(!s.is_busy());
1017        assert!(s.current_turn_id().is_none());
1018    }
1019
1020    #[test]
1021    fn turn_state_accepts_matches_id() {
1022        let s = TurnState::Generating {
1023            id: TurnId(7),
1024            started: SystemTime::now(),
1025            partial_text: String::new(),
1026            partial_reasoning: String::new(),
1027            tokens: 0,
1028            phase: GenPhase::Sending,
1029            thinking_signature: None,
1030            pending_tool_calls: Vec::new(),
1031        };
1032        assert!(s.accepts(TurnId(7)));
1033        assert!(!s.accepts(TurnId(6)));
1034        assert!(!s.accepts(TurnId(8)));
1035    }
1036
1037    #[test]
1038    fn idle_rejects_all_turn_ids() {
1039        let s = TurnState::Idle;
1040        assert!(!s.accepts(TurnId(1)));
1041        assert!(!s.accepts(TurnId(999)));
1042    }
1043
1044    #[test]
1045    fn fresh_id_allocators_monotonic() {
1046        let mut bundle = IdAllocatorBundle::default();
1047        assert_eq!(bundle.fresh_turn(), TurnId(1));
1048        assert_eq!(bundle.fresh_turn(), TurnId(2));
1049        assert_eq!(bundle.fresh_tool_call(), ToolCallId(1));
1050        // Cross-allocator independence — fresh turns don't consume
1051        // tool call IDs.
1052    }
1053
1054    #[test]
1055    fn tool_outcome_cancelled_content_is_placeholder() {
1056        let o = ToolOutcome::cancelled();
1057        assert!(o.was_cancelled());
1058        let content = o.as_tool_message_content();
1059        assert!(content.contains("cancelled"));
1060    }
1061
1062    #[test]
1063    fn tool_outcome_finished_returns_output_verbatim() {
1064        let o = ToolOutcome::success("hello world", "hello world", 0.1);
1065        assert_eq!(o.as_tool_message_content(), "hello world");
1066        assert!(!o.was_cancelled());
1067    }
1068
1069    #[test]
1070    fn session_append_records_message() {
1071        let mut s = mock_state();
1072        s.session.append(ChatMessage::user("hi"));
1073        assert_eq!(s.session.messages().len(), 1);
1074        assert_eq!(s.session.messages()[0].content, "hi");
1075    }
1076}