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}