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