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