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