Skip to main content

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        /// When tool execution started, so the status line can show elapsed
481        /// time (a long-running command — `npm run dev`, a slow build — would
482        /// otherwise look frozen at 0s).
483        started: SystemTime,
484        calls: Vec<PendingToolCall>,
485        outcomes: Vec<Option<ToolOutcome>>,
486    },
487    /// A manual `/compact` request is summarizing history. Auto
488    /// compaction runs while `Generating` because it is preflight for
489    /// the same user turn; this variant is only for explicit user
490    /// compaction.
491    Compacting {
492        id: TurnId,
493        started: SystemTime,
494        trigger: CompactionTrigger,
495    },
496    /// `CancelTurn` was dispatched. The reducer has already emitted a
497    /// `Cmd::CancelScope` — now we wait for the final `Cancelled` /
498    /// `StreamDone` that the effect runner sends back when the scope's
499    /// `JoinSet` drains. Only then do we transition to `Idle`.
500    ///
501    /// Stuck in `Cancelling` too long = effect runner has a bug. UI
502    /// surfaces a "cleanup taking a while…" hint after 2s.
503    Cancelling {
504        id: TurnId,
505        since: SystemTime,
506    },
507}
508
509impl TurnState {
510    pub fn id(&self) -> Option<TurnId> {
511        match self {
512            TurnState::Idle => None,
513            TurnState::Generating { id, .. }
514            | TurnState::ExecutingTools { id, .. }
515            | TurnState::Compacting { id, .. }
516            | TurnState::Cancelling { id, .. } => Some(*id),
517        }
518    }
519
520    /// True when a `Msg` tagged with the given `TurnId` should be
521    /// accepted. Events from prior turns return false — the reducer's
522    /// first line on every effect-result arm.
523    pub fn accepts(&self, event_turn: TurnId) -> bool {
524        self.id() == Some(event_turn)
525    }
526}
527
528/// Sub-phase of `Generating`. Informational — the reducer updates it
529/// as the provider's stream progresses so the UI can show a meaningful
530/// status ("Thinking…" vs "Sending…" vs "Streaming").
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum GenPhase {
533    /// Request dispatched, awaiting first byte.
534    Sending,
535    /// First chunk was reasoning content — currently inside a
536    /// thinking/reasoning block.
537    Thinking,
538    /// Streaming assistant content (post-thinking, or no thinking at
539    /// all).
540    Streaming,
541}
542
543/// One pending tool call that the model has asked us to execute. Wraps
544/// the wire-format tool call with an internal ID + the original
545/// provider-native structure so the reducer never loses provenance.
546#[derive(Debug, Clone)]
547pub struct PendingToolCall {
548    pub call_id: ToolCallId,
549    /// The raw tool call as it appeared in the model's response.
550    /// Preserved verbatim so the follow-up tool-result message can
551    /// reference the right function name + id on the wire.
552    pub source: ModelToolCall,
553}
554
555/// Outcome of a single tool execution.
556///
557/// `model_content` is the text that goes back to the model in the
558/// follow-up tool message. Everything else is Mermaid-owned
559/// structure for rendering, replay, process tracking, and timeline
560/// inspection.
561#[derive(Debug, Clone, PartialEq)]
562pub struct ToolOutcome {
563    pub status: ToolStatus,
564    pub summary: String,
565    pub model_content: String,
566    pub error: Option<String>,
567    pub metadata: Box<ToolRunMetadata>,
568    pub artifacts: Vec<ToolArtifact>,
569    pub duration_secs: Option<f64>,
570}
571
572impl ToolOutcome {
573    pub fn success(
574        model_content: impl Into<String>,
575        summary: impl Into<String>,
576        duration_secs: f64,
577    ) -> Self {
578        let duration = Some(duration_secs);
579        let metadata = ToolRunMetadata {
580            duration_secs: duration,
581            ..ToolRunMetadata::default()
582        };
583        Self {
584            status: ToolStatus::Success,
585            summary: summary.into(),
586            model_content: model_content.into(),
587            error: None,
588            metadata: Box::new(metadata),
589            artifacts: Vec::new(),
590            duration_secs: duration,
591        }
592    }
593
594    pub fn error(error: impl Into<String>, duration_secs: f64) -> Self {
595        let error = error.into();
596        let duration = Some(duration_secs);
597        Self {
598            status: ToolStatus::Error,
599            summary: error.clone(),
600            model_content: format!("Error: {}", error),
601            error: Some(error),
602            metadata: Box::new(ToolRunMetadata {
603                duration_secs: duration,
604                ..ToolRunMetadata::default()
605            }),
606            artifacts: Vec::new(),
607            duration_secs: duration,
608        }
609    }
610
611    pub fn cancelled() -> Self {
612        Self {
613            status: ToolStatus::Cancelled,
614            summary: "[cancelled]".to_string(),
615            model_content: "[Tool call skipped: the user cancelled before execution]".to_string(),
616            error: None,
617            metadata: Box::new(ToolRunMetadata::default()),
618            artifacts: Vec::new(),
619            duration_secs: None,
620        }
621    }
622
623    pub fn with_metadata(mut self, mut metadata: ToolRunMetadata) -> Self {
624        metadata.duration_secs = self.duration_secs;
625        self.metadata = Box::new(metadata);
626        self
627    }
628
629    pub fn with_artifacts(mut self, artifacts: Vec<ToolArtifact>) -> Self {
630        self.artifacts = artifacts.clone();
631        self.metadata.artifacts = artifacts;
632        self
633    }
634
635    pub fn with_images(self, images: Vec<String>) -> Self {
636        self.with_artifacts(
637            images
638                .into_iter()
639                .map(|data| ToolArtifact::Image { data })
640                .collect(),
641        )
642    }
643
644    pub fn was_cancelled(&self) -> bool {
645        self.status == ToolStatus::Cancelled
646    }
647
648    pub fn is_success(&self) -> bool {
649        self.status == ToolStatus::Success
650    }
651
652    pub fn output(&self) -> &str {
653        &self.model_content
654    }
655
656    pub fn error_message(&self) -> Option<&str> {
657        self.error.as_deref()
658    }
659
660    pub fn images(&self) -> Option<Vec<String>> {
661        let images: Vec<String> = self
662            .artifacts
663            .iter()
664            .filter_map(|artifact| match artifact {
665                ToolArtifact::Image { data } => Some(data.clone()),
666                _ => None,
667            })
668            .collect();
669        if images.is_empty() {
670            None
671        } else {
672            Some(images)
673        }
674    }
675
676    /// Convert to a textual representation suitable for embedding in
677    /// the follow-up `tool` role message. Cancellation produces a
678    /// placeholder so the model sees "this was skipped" rather than
679    /// the history becoming malformed.
680    pub fn as_tool_message_content(&self) -> String {
681        self.model_content.clone()
682    }
683}
684
685/// All UI-only state. Things in `UiState` never affect what gets sent
686/// to the model — only what the user sees.
687#[derive(Debug, Clone, Default)]
688pub struct UiState {
689    pub mode: UiMode,
690    pub input_buffer: String,
691    /// Byte position within `input_buffer`. The reducer normalizes to
692    /// a UTF-8 char boundary on every mutation via
693    /// `floor_char_boundary`, so widgets can slice safely.
694    pub input_cursor: usize,
695    /// Pending image pastes queued for the next user message.
696    pub attachments: Vec<Attachment>,
697    /// When true, keyboard focus is on the attachment bar (up arrow
698    /// from input moves focus up here; Esc returns focus to input).
699    pub attachment_focused: bool,
700    /// Highlighted attachment index when focused. Ignored when
701    /// `attachment_focused` is false.
702    pub attachment_selected: usize,
703    /// Scroll offset for the chat pane.
704    pub chat_scroll: usize,
705    /// When the slash-palette is open, this holds the filter prefix
706    /// (typed after the leading `/`) so the palette widget can
707    /// re-query the registry.
708    pub palette_filter: String,
709    /// When `Some(i)`, the palette has a highlighted row. `None` =
710    /// closed / not showing.
711    pub palette_cursor: Option<usize>,
712    /// Messages the user typed while a turn was in flight. The
713    /// reducer pops the oldest and auto-submits on a successful
714    /// `StreamDone`. FIFO order.
715    pub queued_messages: VecDeque<String>,
716    /// Last terminal title dispatched via `Cmd::SetTerminalTitle`.
717    /// Arms that change `session.conversation.title` consult this
718    /// and emit a fresh `SetTerminalTitle` only on diff.
719    pub last_title_dispatched: Option<String>,
720    /// Follow-up `Msg`s the reducer has queued for re-entry. The
721    /// outer `update()` drains this after each single-step call so
722    /// a handler can emit a synthetic event (e.g. Enter-on-slash
723    /// queuing `Msg::Slash(cmd)`) without self-invoking the
724    /// reducer. Bounded drain depth guards against runaway loops.
725    pub pending_msgs: VecDeque<Msg>,
726    /// Up-arrow history navigation cursor into
727    /// `session.conversation.input_history`. `None` = not
728    /// navigating (input_buffer is whatever the user typed).
729    /// `Some(i)` = currently displaying history entry at index `i`
730    /// from the END (0 = newest).
731    pub input_history_cursor: Option<usize>,
732    /// Whatever the user had typed before hitting Up. Preserved so
733    /// stepping past the newest history entry with Down restores
734    /// the partial input unchanged. Cleared on any non-nav key.
735    pub history_draft: String,
736    /// Running accumulator for mouse-wheel scroll events (F13). The
737    /// reducer adds the delta here on `Msg::MouseScroll`; the render
738    /// layer compares against its last-seen snapshot and applies the
739    /// diff to the chat pane's `ChatState`. This keeps the reducer
740    /// pure — it doesn't touch render-layer state, it just publishes
741    /// an intent. `i32` wraps at ~2 billion scrolls (never).
742    pub mouse_scroll_accum: i32,
743    /// Whether committed reasoning/thinking blocks are expanded in
744    /// the chat transcript. Hidden by default to keep the TUI focused
745    /// on user-facing work while retaining provider-required history.
746    pub show_reasoning: bool,
747}
748
749/// Top-level UI mode. Like `TurnState` this is a sum type instead of a
750/// zoo of independent bools. `EditingInput` is the default.
751#[derive(Debug, Clone, PartialEq, Eq, Default)]
752pub enum UiMode {
753    #[default]
754    EditingInput,
755    /// Slash-command palette open (user typed `/`).
756    Palette,
757    /// `/load` — list of saved conversations visible. `candidates`
758    /// holds what the effect handler returned; `cursor` is the
759    /// highlighted row.
760    ConversationList {
761        candidates: Vec<ConversationSummary>,
762        cursor: usize,
763    },
764    /// `/model` — list of available models visible.
765    ModelList,
766}
767
768/// Summary row for the conversation picker. Produced by
769/// `Cmd::ListConversations` → `Msg::ConversationsListed`.
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct ConversationSummary {
772    pub id: String,
773    pub title: String,
774    pub message_count: usize,
775    pub updated_at: String,
776}
777
778/// One pasted image, ready to send. Kept in the reducer state — not on
779/// disk — because the image hasn't been confirmed for a message yet.
780#[derive(Debug, Clone)]
781pub struct Attachment {
782    pub id: u64,
783    pub base64_data: String,
784    /// Temp file path (written by the effect runner when the paste
785    /// event comes in, so the TUI can show a preview).
786    pub temp_path: PathBuf,
787    pub size_bytes: usize,
788    pub format: String,
789}
790
791/// MCP server lifecycle state. Mutation is driven by `Msg::McpServer*`
792/// events emitted from `effect::mcp` when a server starts, advertises
793/// tools, or exits.
794#[derive(Debug, Clone, Default)]
795pub struct McpState {
796    pub servers: HashMap<String, McpServerEntry>,
797}
798
799#[derive(Debug, Clone)]
800pub struct McpServerEntry {
801    pub config: McpServerConfig,
802    pub status: McpServerStatus,
803    /// Tools advertised by the server. Populated on the
804    /// `McpServerReady` event; reducer exposes these to the model
805    /// when building the tool list for the next request.
806    pub tools: Vec<McpToolSpec>,
807}
808
809#[derive(Debug, Clone, PartialEq, Eq)]
810pub enum McpServerStatus {
811    /// `initialize` request dispatched, not yet acknowledged.
812    Starting,
813    Ready,
814    Errored {
815        reason: String,
816    },
817    Stopped,
818}
819
820/// Subset of the MCP `ToolDefinition` carried in reducer state. The
821/// reducer doesn't need the full schema; the effect layer uses the
822/// server name + tool name to route, and the reducer uses the
823/// description for palette display.
824#[derive(Debug, Clone)]
825pub struct McpToolSpec {
826    pub name: String,
827    pub description: String,
828    pub input_schema: serde_json::Value,
829}
830
831/// A pending user confirmation (modal). Examples: confirming `/clear`,
832/// confirming overwrite of an existing file on `/save <name>`.
833#[derive(Debug, Clone)]
834pub struct Confirmation {
835    pub prompt: String,
836    pub accept_msg_token: ConfirmationTarget,
837}
838
839/// What to do when the user confirms. The reducer translates
840/// `Msg::ConfirmAccepted` into a secondary dispatch based on this.
841#[derive(Debug, Clone)]
842pub enum ConfirmationTarget {
843    ClearConversation,
844}
845
846/// One tool action awaiting inline approval. Built by the policy gate and
847/// delivered via `Msg::ApprovalRequested`; rendered as a modal. The `prompt`
848/// body is pre-formatted by the gate (command / path / summary, plus any
849/// Auto-review reason) so the render layer stays dumb.
850#[derive(Debug, Clone)]
851pub struct PendingApproval {
852    pub turn: TurnId,
853    pub call_id: ToolCallId,
854    pub tool: String,
855    /// `RiskClass::as_str()` — shown on the title line.
856    pub risk: String,
857    pub kind: ApprovalKind,
858    /// Pre-formatted body (the command/path being run + any classifier reason).
859    pub prompt: String,
860    /// What "don't ask again" (option 2) will allowlist, shown in the prompt.
861    pub allowlist_scope: String,
862    /// Highlighted option for arrow-key navigation: 0 = Yes, 1 = Yes-always,
863    /// 2 = No. Number keys (1/2/3) still resolve directly regardless of this.
864    pub selected_option: usize,
865}
866
867/// The user's answer to an approval prompt.
868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
869pub enum ApprovalChoice {
870    Approve,
871    ApproveAlways,
872    Deny,
873}
874
875/// Category of the gated action — drives the prompt's label. Mirrors the
876/// runtime `ToolCategory` but lives in `domain` so the pure reducer needn't
877/// depend on `providers`.
878#[derive(Debug, Clone, Copy, PartialEq, Eq)]
879pub enum ApprovalKind {
880    Shell,
881    FileMutation,
882    Web,
883    Mcp,
884    Subagent,
885    ComputerUse,
886    Classify,
887}
888
889/// Transient status line shown under the input box. Self-clears after
890/// its kind's expected lifetime — `Persistent` entries stay until
891/// explicitly dismissed.
892#[derive(Debug, Clone)]
893pub struct StatusLine {
894    pub text: String,
895    pub kind: StatusKind,
896    pub shown_at: SystemTime,
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
900pub enum StatusKind {
901    Info,
902    Warn,
903    Error,
904    /// Stays until the next turn or explicit dismissal.
905    Persistent,
906}
907
908/// All ID allocators for the session. Grouped so the reducer can
909/// request any of them through a single `&mut state.ids`.
910#[derive(Debug, Clone, Copy, Default)]
911pub struct IdAllocatorBundle {
912    pub turn: IdAllocator,
913    pub tool_call: IdAllocator,
914}
915
916impl IdAllocatorBundle {
917    pub fn fresh_turn(&mut self) -> TurnId {
918        TurnId(self.turn.next())
919    }
920
921    pub fn fresh_tool_call(&mut self) -> ToolCallId {
922        ToolCallId(self.tool_call.next())
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    fn mock_state() -> State {
931        State::new(
932            Config::default(),
933            PathBuf::from("/tmp/project"),
934            "ollama/test".to_string(),
935        )
936    }
937
938    #[test]
939    fn fresh_state_is_idle() {
940        let s = mock_state();
941        assert!(matches!(s.turn, TurnState::Idle));
942        assert!(!s.is_busy());
943        assert!(s.current_turn_id().is_none());
944    }
945
946    #[test]
947    fn turn_state_accepts_matches_id() {
948        let s = TurnState::Generating {
949            id: TurnId(7),
950            started: SystemTime::now(),
951            partial_text: String::new(),
952            partial_reasoning: String::new(),
953            tokens: 0,
954            phase: GenPhase::Sending,
955            thinking_signature: None,
956            pending_tool_calls: Vec::new(),
957        };
958        assert!(s.accepts(TurnId(7)));
959        assert!(!s.accepts(TurnId(6)));
960        assert!(!s.accepts(TurnId(8)));
961    }
962
963    #[test]
964    fn idle_rejects_all_turn_ids() {
965        let s = TurnState::Idle;
966        assert!(!s.accepts(TurnId(1)));
967        assert!(!s.accepts(TurnId(999)));
968    }
969
970    #[test]
971    fn fresh_id_allocators_monotonic() {
972        let mut bundle = IdAllocatorBundle::default();
973        assert_eq!(bundle.fresh_turn(), TurnId(1));
974        assert_eq!(bundle.fresh_turn(), TurnId(2));
975        assert_eq!(bundle.fresh_tool_call(), ToolCallId(1));
976        // Cross-allocator independence — fresh turns don't consume
977        // tool call IDs.
978    }
979
980    #[test]
981    fn tool_outcome_cancelled_content_is_placeholder() {
982        let o = ToolOutcome::cancelled();
983        assert!(o.was_cancelled());
984        let content = o.as_tool_message_content();
985        assert!(content.contains("cancelled"));
986    }
987
988    #[test]
989    fn tool_outcome_finished_returns_output_verbatim() {
990        let o = ToolOutcome::success("hello world", "hello world", 0.1);
991        assert_eq!(o.as_tool_message_content(), "hello world");
992        assert!(!o.was_cancelled());
993    }
994
995    #[test]
996    fn session_append_records_message() {
997        let mut s = mock_state();
998        s.session.append(ChatMessage::user("hi"));
999        assert_eq!(s.session.messages().len(), 1);
1000        assert_eq!(s.session.messages()[0].content, "hi");
1001    }
1002}