Skip to main content

cyril_core/
session.rs

1use std::path::PathBuf;
2
3use agent_client_protocol as acp;
4
5/// Config option ID used by the ACP server for the current model selection.
6pub const CONFIG_KEY_MODEL: &str = "model";
7
8/// An available agent mode from the session.
9#[derive(Debug, Clone)]
10pub struct AvailableMode {
11    pub id: String,
12    pub name: String,
13}
14
15/// Owns all session-level state: IDs, modes, config options, and working directory.
16///
17/// This is the single source of truth for session data. The toolbar borrows
18/// from this struct at render time.
19#[derive(Debug)]
20pub struct SessionContext {
21    pub id: Option<acp::SessionId>,
22    pub cwd: PathBuf,
23    available_modes: Vec<AvailableMode>,
24    config_options: Vec<acp::SessionConfigOption>,
25    context_usage_pct: Option<f64>,
26    current_mode_id: Option<String>,
27    cached_model: Option<String>,
28}
29
30impl SessionContext {
31    pub fn new(cwd: PathBuf) -> Self {
32        Self {
33            id: None,
34            available_modes: Vec::new(),
35            config_options: Vec::new(),
36            cwd,
37            context_usage_pct: None,
38            current_mode_id: None,
39            cached_model: None,
40        }
41    }
42
43    /// Set the session ID (typically after creating or loading a session).
44    pub fn set_session_id(&mut self, session_id: acp::SessionId) {
45        self.id = Some(session_id);
46    }
47
48    /// The available agent modes advertised by the session.
49    pub fn available_modes(&self) -> &[AvailableMode] {
50        &self.available_modes
51    }
52
53    /// The current mode ID, if any.
54    pub fn current_mode_id(&self) -> Option<&str> {
55        self.current_mode_id.as_deref()
56    }
57
58    /// Set the current mode ID (from a `ModeChanged` notification).
59    pub fn set_current_mode_id(&mut self, mode_id: String) {
60        self.current_mode_id = Some(mode_id);
61    }
62
63    /// The current context usage percentage, if reported.
64    pub fn context_usage_pct(&self) -> Option<f64> {
65        self.context_usage_pct
66    }
67
68    /// Set the context usage percentage (from Kiro metadata notifications).
69    pub fn set_context_usage_pct(&mut self, pct: f64) {
70        self.context_usage_pct = Some(pct);
71    }
72
73    /// Store mode info from a NewSessionResponse.
74    pub fn set_modes(&mut self, modes: &acp::SessionModeState) {
75        self.current_mode_id = Some(modes.current_mode_id.to_string());
76        self.available_modes = modes
77            .available_modes
78            .iter()
79            .map(|m| AvailableMode {
80                id: m.id.to_string(),
81                name: m.name.clone(),
82            })
83            .collect();
84    }
85
86    /// Store config options (model, etc.) from a session response or update notification.
87    pub fn set_config_options(&mut self, options: Vec<acp::SessionConfigOption>) {
88        self.config_options = options;
89        self.cached_model = self.compute_current_model();
90    }
91
92    /// Optimistically update the cached model for immediate UI feedback.
93    /// The server's `ConfigOptionsUpdated` event will confirm or overwrite this.
94    pub fn set_optimistic_model(&mut self, model: String) {
95        self.cached_model = Some(model);
96    }
97
98    /// Return the cached model value (O(1) per frame).
99    pub fn current_model(&self) -> Option<&str> {
100        self.cached_model.as_deref()
101    }
102
103    /// Extract the current model value from stored config options.
104    fn compute_current_model(&self) -> Option<String> {
105        self.config_options.iter().find_map(|opt| {
106            if opt.id.to_string() == CONFIG_KEY_MODEL {
107                if let acp::SessionConfigKind::Select(ref select) = opt.kind {
108                    return Some(select.current_value.to_string());
109                }
110            }
111            None
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn new_session_has_no_id() {
122        let ctx = SessionContext::new(PathBuf::from("/tmp"));
123        assert!(ctx.id.is_none());
124        assert!(ctx.available_modes.is_empty());
125        assert!(ctx.config_options.is_empty());
126        assert!(ctx.context_usage_pct.is_none());
127        assert!(ctx.current_mode_id.is_none());
128        assert!(ctx.current_model().is_none());
129    }
130
131    #[test]
132    fn set_session_id_stores_id() {
133        let mut ctx = SessionContext::new(PathBuf::from("/tmp"));
134        let id = acp::SessionId::from("test-session-123".to_string());
135        ctx.set_session_id(id);
136        assert!(ctx.id.is_some());
137        assert_eq!(ctx.id.unwrap().to_string(), "test-session-123");
138    }
139
140    #[test]
141    fn current_model_returns_none_when_no_config() {
142        let ctx = SessionContext::new(PathBuf::from("/tmp"));
143        assert!(ctx.current_model().is_none());
144    }
145
146    #[test]
147    fn current_model_returns_value_from_config_options() {
148        let mut ctx = SessionContext::new(PathBuf::from("/tmp"));
149        let option = acp::SessionConfigOption::select(
150            "model",
151            "Model",
152            "claude-sonnet-4-6",
153            vec![acp::SessionConfigSelectOption::new(
154                "claude-sonnet-4-6",
155                "Claude Sonnet 4.6",
156            )],
157        );
158        ctx.set_config_options(vec![option]);
159        assert_eq!(ctx.current_model(), Some("claude-sonnet-4-6"));
160    }
161
162    #[test]
163    fn set_modes_populates_available_modes() {
164        let mut ctx = SessionContext::new(PathBuf::from("/tmp"));
165        let modes = acp::SessionModeState::new(
166            "code",
167            vec![
168                acp::SessionMode::new("code", "Code"),
169                acp::SessionMode::new("chat", "Chat"),
170            ],
171        );
172        ctx.set_modes(&modes);
173        assert_eq!(ctx.current_mode_id.as_deref(), Some("code"));
174        assert_eq!(ctx.available_modes.len(), 2);
175        assert_eq!(ctx.available_modes[0].id, "code");
176        assert_eq!(ctx.available_modes[0].name, "Code");
177        assert_eq!(ctx.available_modes[1].id, "chat");
178        assert_eq!(ctx.available_modes[1].name, "Chat");
179    }
180
181    #[test]
182    fn set_optimistic_model_updates_cached_value() {
183        let mut ctx = SessionContext::new(PathBuf::from("/tmp"));
184        assert!(ctx.current_model().is_none());
185        ctx.set_optimistic_model("claude-sonnet-4-6".to_string());
186        assert_eq!(ctx.current_model(), Some("claude-sonnet-4-6"));
187    }
188
189    #[test]
190    fn set_config_options_caches_model() {
191        let mut ctx = SessionContext::new(PathBuf::from("/tmp"));
192        let option = acp::SessionConfigOption::select(
193            "model",
194            "Model",
195            "claude-opus-4",
196            vec![
197                acp::SessionConfigSelectOption::new("claude-opus-4", "Claude Opus 4"),
198                acp::SessionConfigSelectOption::new("claude-sonnet-4-6", "Claude Sonnet 4.6"),
199            ],
200        );
201        ctx.set_config_options(vec![option]);
202
203        // current_model() returns the cached value without recomputing
204        assert_eq!(ctx.current_model(), Some("claude-opus-4"));
205
206        // Adding a non-model config option does not affect the model cache
207        let other = acp::SessionConfigOption::select(
208            "thought_level",
209            "Thought Level",
210            "high",
211            vec![acp::SessionConfigSelectOption::new("high", "High")],
212        );
213        ctx.set_config_options(vec![other]);
214        assert!(ctx.current_model().is_none());
215    }
216}