Skip to main content

mermaid_cli/tui/state/
model.rs

1//! Model state management
2//!
3//! Handles LLM configuration and identity.
4
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8use crate::models::{Model, ModelConfig, ReasoningCapability, ReasoningLevel};
9
10/// Model state - LLM configuration and identity
11pub struct ModelState {
12    pub model: Arc<RwLock<Box<dyn Model>>>,
13    pub model_id: String,
14    pub model_name: String,
15    /// Vision support state:
16    /// - Some(true) = model supports vision
17    /// - Some(false) = model does not support vision (detected from error)
18    /// - None = unknown (optimistic default)
19    pub vision_supported: Option<bool>,
20    /// Cached snapshot of the underlying model's `supports_reasoning`
21    /// capability. Lives here so the sync render path doesn't have to
22    /// `try_read()` the `tokio::sync::RwLock<Box<dyn Model>>` once per
23    /// frame to compute snap-divergence (Step 5b). Refreshed on
24    /// `/model` switch by the slash-command handler.
25    pub supported_reasoning: ReasoningCapability,
26    /// Base model configuration from app config. Used by build_config() to
27    /// produce API-ready ModelConfig with runtime-only fields set.
28    pub base_config: ModelConfig,
29}
30
31impl ModelState {
32    pub fn new(model: Box<dyn Model>, model_id: String, base_config: ModelConfig) -> Self {
33        let model_name = model.name().to_string();
34        let supported_reasoning = model.capabilities().supports_reasoning.clone();
35        Self {
36            model: Arc::new(RwLock::new(model)),
37            model_id,
38            model_name,
39            vision_supported: None,
40            supported_reasoning,
41            base_config,
42        }
43    }
44
45    /// Get a reference to the model for reading
46    pub fn model_ref(&self) -> &Arc<RwLock<Box<dyn Model>>> {
47        &self.model
48    }
49
50    /// Cycle the reasoning level for the next chat call. Cycle order:
51    /// `None → Low → Medium → High → Max → None`. `Minimal` and `XHigh`
52    /// are omitted from the cycle (specialist tiers restricted to
53    /// specific providers — OpenAI GPT-5 `minimal`, GPT-5.2/Opus-4.7
54    /// `xhigh`). Both remain reachable via `/reasoning <level>`. Returns
55    /// the new level so the caller can render a status message and persist.
56    pub fn cycle_reasoning(&mut self) -> ReasoningLevel {
57        let next = match self.base_config.reasoning {
58            ReasoningLevel::None => ReasoningLevel::Low,
59            ReasoningLevel::Low => ReasoningLevel::Medium,
60            ReasoningLevel::Medium => ReasoningLevel::High,
61            ReasoningLevel::High => ReasoningLevel::Max,
62            ReasoningLevel::Max => ReasoningLevel::None,
63            // `Minimal` isn't in the cycle, but if a slash command put us
64            // there, the next Alt+T press lands on `Low` (rank+1).
65            ReasoningLevel::Minimal => ReasoningLevel::Low,
66            // `XHigh` isn't in the cycle either — if the user arrived via
67            // `/reasoning xhigh`, Alt+T drops them to `None` to start the
68            // standard cycle fresh rather than bouncing back up to Max.
69            ReasoningLevel::XHigh => ReasoningLevel::None,
70        };
71        self.base_config.reasoning = next;
72        next
73    }
74
75    /// Set the reasoning level explicitly (used by `/reasoning <level>`).
76    pub fn set_reasoning(&mut self, level: ReasoningLevel) {
77        self.base_config.reasoning = level;
78    }
79
80    /// Build a ModelConfig for API calls using current model state.
81    /// Clones the base config and sets runtime-only fields.
82    pub fn build_config(&self) -> ModelConfig {
83        let mut config = self.base_config.clone();
84        config.model = self.model_id.clone();
85        config
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    /// Stub model used to construct a `ModelState` in tests without
94    /// needing a real adapter / network.
95    struct StubModel;
96
97    #[async_trait::async_trait]
98    impl Model for StubModel {
99        async fn chat(
100            &self,
101            _messages: &[crate::models::ChatMessage],
102            _config: &ModelConfig,
103            _stream_callback: Option<crate::models::StreamCallback>,
104        ) -> crate::models::Result<crate::models::ModelResponse> {
105            unimplemented!("stub")
106        }
107        fn name(&self) -> &str {
108            "stub"
109        }
110        fn capabilities(&self) -> &crate::models::ModelCapabilities {
111            use std::sync::OnceLock;
112            static CAPS: OnceLock<crate::models::ModelCapabilities> = OnceLock::new();
113            CAPS.get_or_init(crate::models::ModelCapabilities::ollama_default)
114        }
115        async fn list_models(&self) -> crate::models::Result<Vec<String>> {
116            Ok(vec![])
117        }
118    }
119
120    fn make_state(initial: ReasoningLevel) -> ModelState {
121        let base = ModelConfig {
122            reasoning: initial,
123            ..Default::default()
124        };
125        ModelState::new(Box::new(StubModel), "stub/model".to_string(), base)
126    }
127
128    #[test]
129    fn cycle_reasoning_visits_5_stops_starting_from_none() {
130        let mut state = make_state(ReasoningLevel::None);
131        assert_eq!(state.cycle_reasoning(), ReasoningLevel::Low);
132        assert_eq!(state.cycle_reasoning(), ReasoningLevel::Medium);
133        assert_eq!(state.cycle_reasoning(), ReasoningLevel::High);
134        assert_eq!(state.cycle_reasoning(), ReasoningLevel::Max);
135        assert_eq!(state.cycle_reasoning(), ReasoningLevel::None);
136    }
137
138    #[test]
139    fn cycle_reasoning_after_minimal_lands_on_low() {
140        // `Minimal` is reachable via `/reasoning minimal` but not part of
141        // the cycle. The next Alt+T press should resume the cycle, not
142        // panic or stay on Minimal.
143        let mut state = make_state(ReasoningLevel::Minimal);
144        assert_eq!(state.cycle_reasoning(), ReasoningLevel::Low);
145    }
146
147    #[test]
148    fn cycle_reasoning_after_xhigh_lands_on_none() {
149        // `XHigh` is reachable via `/reasoning xhigh` but skipped by the
150        // cycle. From XHigh, Alt+T drops to None to start the standard
151        // cycle fresh (matching the "exit specialist tier" intent).
152        let mut state = make_state(ReasoningLevel::XHigh);
153        assert_eq!(state.cycle_reasoning(), ReasoningLevel::None);
154    }
155
156    #[test]
157    fn set_reasoning_updates_base_config() {
158        let mut state = make_state(ReasoningLevel::Medium);
159        state.set_reasoning(ReasoningLevel::Max);
160        assert_eq!(state.base_config.reasoning, ReasoningLevel::Max);
161    }
162
163    #[test]
164    fn build_config_propagates_reasoning_from_base() {
165        let mut state = make_state(ReasoningLevel::High);
166        let config = state.build_config();
167        assert_eq!(config.reasoning, ReasoningLevel::High);
168        // Cycling mutates only base_config; existing build_config clones
169        // are unaffected (lock-free render path).
170        state.cycle_reasoning();
171        assert_eq!(config.reasoning, ReasoningLevel::High);
172        assert_eq!(state.base_config.reasoning, ReasoningLevel::Max);
173    }
174
175    /// `supported_reasoning` is cached at construction so the sync render
176    /// path doesn't hit the `tokio::sync::RwLock<Box<dyn Model>>` per
177    /// frame. Verify it matches what the underlying model advertises.
178    #[test]
179    fn model_state_caches_supported_reasoning_at_construction() {
180        let state = make_state(ReasoningLevel::Medium);
181        // StubModel uses `ModelCapabilities::ollama_default()` which is
182        // `ReasoningCapability::Binary` — verify the cached snapshot
183        // matches.
184        let expected = crate::models::ModelCapabilities::ollama_default().supports_reasoning;
185        assert_eq!(state.supported_reasoning, expected);
186    }
187}