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