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