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