1use atm_core::{SessionDomain, StatusLineData};
9use serde::Deserialize;
10
11#[derive(Debug, Clone, Deserialize)]
16pub struct RawStatusLine {
17 pub session_id: String,
18 #[serde(default)]
19 pub transcript_path: Option<String>,
20 #[serde(default)]
21 pub cwd: Option<String>,
22 #[serde(default)]
23 pub model: Option<RawModel>,
24 #[serde(default)]
25 pub workspace: Option<RawWorkspace>,
26 #[serde(default)]
27 pub version: Option<String>,
28 #[serde(default)]
29 pub cost: Option<RawCost>,
30 #[serde(default)]
31 pub context_window: Option<RawContextWindow>,
32 #[serde(default)]
33 pub exceeds_200k_tokens: Option<bool>,
34 #[serde(default)]
36 pub pid: Option<u32>,
37 #[serde(default)]
39 pub tmux_pane: Option<String>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct RawModel {
44 pub id: String,
45 #[serde(default)]
46 pub display_name: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct RawWorkspace {
51 #[serde(default)]
52 pub current_dir: Option<String>,
53 #[serde(default)]
54 pub project_dir: Option<String>,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58pub struct RawCost {
59 pub total_cost_usd: f64,
60 pub total_duration_ms: u64,
61 #[serde(default)]
62 pub total_api_duration_ms: u64,
63 #[serde(default)]
64 pub total_lines_added: u64,
65 #[serde(default)]
66 pub total_lines_removed: u64,
67}
68
69#[derive(Debug, Clone, Deserialize)]
70pub struct RawContextWindow {
71 #[serde(default)]
72 pub total_input_tokens: u64,
73 #[serde(default)]
74 pub total_output_tokens: u64,
75 #[serde(default = "default_context_window_size")]
76 pub context_window_size: u32,
77 #[serde(default)]
79 pub used_percentage: Option<f64>,
80 #[serde(default)]
82 pub remaining_percentage: Option<f64>,
83 #[serde(default)]
84 pub current_usage: Option<RawCurrentUsage>,
85}
86
87fn default_context_window_size() -> u32 {
88 200_000
89}
90
91#[derive(Debug, Clone, Deserialize)]
92pub struct RawCurrentUsage {
93 #[serde(default)]
94 pub input_tokens: u64,
95 #[serde(default)]
96 pub output_tokens: u64,
97 #[serde(default)]
98 pub cache_creation_input_tokens: u64,
99 #[serde(default)]
100 pub cache_read_input_tokens: u64,
101}
102
103impl RawStatusLine {
104 pub fn to_status_line_data(&self) -> Option<StatusLineData> {
108 let model = self.model.as_ref()?;
109 let cost = self.cost.as_ref();
110 let context = self.context_window.as_ref();
111 let current = context.and_then(|c| c.current_usage.as_ref());
112
113 Some(StatusLineData {
114 session_id: self.session_id.clone(),
115 model_id: model.id.clone(),
116 model_display_name: model.display_name.clone(),
117 cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
118 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
119 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
120 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
121 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
122 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
123 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
124 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
125 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
126 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
127 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
128 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
129 cwd: self.cwd.clone(),
130 version: self.version.clone(),
131 })
132 }
133
134 pub fn to_session_domain(&self) -> Option<SessionDomain> {
137 let data = self.to_status_line_data()?;
138 Some(SessionDomain::from_status_line(&data))
139 }
140
141 pub fn update_session(&self, session: &mut SessionDomain) -> bool {
145 use atm_core::Model;
146
147 if let Some(model) = &self.model {
149 let parsed = Model::from_id(&model.id);
150 session.model = parsed;
151
152 if parsed.is_unknown() && !model.id.is_empty() {
154 session.model_display_override = Some(
155 model
156 .display_name
157 .clone()
158 .unwrap_or_else(|| atm_core::derive_display_name(&model.id)),
159 );
160 } else {
161 session.model_display_override = None;
162 }
163 }
164
165 let cost = self.cost.as_ref();
167 let context = self.context_window.as_ref();
168 let current = context.and_then(|c| c.current_usage.as_ref());
169
170 let data = StatusLineData {
171 session_id: self.session_id.clone(),
172 model_id: String::new(), model_display_name: None, cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
175 total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
176 api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
177 lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
178 lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
179 total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
180 total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
181 context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
182 current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
183 current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
184 cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
185 cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
186 cwd: self.cwd.clone(),
187 version: self.version.clone(),
188 };
189
190 session.update_from_status_line(&data)
191 }
192}
193
194#[cfg(test)]
199mod tests {
200 use super::*;
201 use atm_core::Model;
202
203 #[test]
204 fn test_raw_status_line_parsing() {
205 let json = r#"{
206 "session_id": "test-123",
207 "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
208 "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
209 "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
210 }"#;
211
212 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
213 let session = raw.to_session_domain().expect("should create session");
214
215 assert_eq!(session.id.as_str(), "test-123");
216 assert_eq!(session.model, Model::Opus45);
217 assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
218 assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
219 }
220
221 #[test]
222 fn test_raw_status_line_with_current_usage() {
223 let json = r#"{
224 "session_id": "test-456",
225 "model": {"id": "claude-sonnet-4-20250514"},
226 "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
227 "context_window": {
228 "total_input_tokens": 1000,
229 "total_output_tokens": 500,
230 "context_window_size": 200000,
231 "current_usage": {
232 "input_tokens": 200,
233 "output_tokens": 100,
234 "cache_creation_input_tokens": 50,
235 "cache_read_input_tokens": 25
236 }
237 }
238 }"#;
239
240 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
241 let session = raw.to_session_domain().expect("should create session");
242
243 assert_eq!(session.context.current_input_tokens.as_u64(), 200);
244 assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
245 }
246
247 #[test]
248 fn test_raw_status_line_context_from_current_usage() {
249 let json = r#"{
252 "session_id": "test-pct",
253 "model": {"id": "claude-sonnet-4-20250514"},
254 "context_window": {
255 "total_input_tokens": 50000,
256 "total_output_tokens": 10000,
257 "context_window_size": 200000,
258 "current_usage": {
259 "input_tokens": 1000,
260 "output_tokens": 500,
261 "cache_creation_input_tokens": 2000,
262 "cache_read_input_tokens": 40000
263 }
264 }
265 }"#;
266
267 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
268 let session = raw.to_session_domain().expect("should create session");
269
270 assert_eq!(session.context.context_tokens().as_u64(), 43_000);
273 assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
274 }
275
276 #[test]
277 fn test_raw_status_line_zero_without_current_usage() {
278 let json = r#"{
280 "session_id": "test-fallback",
281 "model": {"id": "claude-sonnet-4-20250514"},
282 "context_window": {
283 "total_input_tokens": 50000,
284 "total_output_tokens": 10000,
285 "context_window_size": 200000
286 }
287 }"#;
288
289 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
290 let session = raw.to_session_domain().expect("should create session");
291
292 assert_eq!(session.context.context_tokens().as_u64(), 0);
294 assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
295 }
296
297 #[test]
298 fn test_raw_status_line_missing_model_returns_none() {
299 let json = r#"{"session_id": "test-789"}"#;
301
302 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
303 assert!(raw.to_session_domain().is_none());
304 }
305
306 #[test]
307 fn test_update_session_fills_in_model() {
308 use atm_core::{AgentType, SessionDomain};
309
310 let mut session = SessionDomain::new(
312 atm_core::SessionId::new("test-discovered"),
313 AgentType::GeneralPurpose,
314 Model::Unknown,
315 );
316 assert_eq!(session.model, Model::Unknown);
317
318 let json = r#"{
320 "session_id": "test-discovered",
321 "model": {"id": "claude-opus-4-5-20251101"},
322 "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
323 }"#;
324
325 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
326 raw.update_session(&mut session);
327
328 assert_eq!(session.model, Model::Opus45);
330 assert!(session.model_display_override.is_none());
332 }
333
334 #[test]
335 fn test_update_session_unknown_model_with_display_name() {
336 use atm_core::{AgentType, SessionDomain};
337
338 let mut session = SessionDomain::new(
339 atm_core::SessionId::new("test-non-anthropic"),
340 AgentType::GeneralPurpose,
341 Model::Unknown,
342 );
343
344 let json = r#"{
346 "session_id": "test-non-anthropic",
347 "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
348 }"#;
349
350 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
351 raw.update_session(&mut session);
352
353 assert_eq!(session.model, Model::Unknown);
354 assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
355 }
356
357 #[test]
358 fn test_update_session_unknown_model_without_display_name() {
359 use atm_core::{AgentType, SessionDomain};
360
361 let mut session = SessionDomain::new(
362 atm_core::SessionId::new("test-unknown"),
363 AgentType::GeneralPurpose,
364 Model::Unknown,
365 );
366
367 let json = r#"{
369 "session_id": "test-unknown",
370 "model": {"id": "gemini-1.5-pro"}
371 }"#;
372
373 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
374 raw.update_session(&mut session);
375
376 assert_eq!(session.model, Model::Unknown);
377 assert_eq!(
378 session.model_display_override.as_deref(),
379 Some("gemini-1.5-pro")
380 );
381 }
382
383 #[test]
384 fn test_new_session_opus46() {
385 let json = r#"{
386 "session_id": "test-opus46",
387 "model": {"id": "claude-opus-4-6"}
388 }"#;
389
390 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
391 let session = raw.to_session_domain().expect("should create session");
392
393 assert_eq!(session.model, Model::Opus46);
394 assert!(session.model_display_override.is_none());
395 }
396
397 #[test]
398 fn test_new_session_non_anthropic_model() {
399 let json = r#"{
400 "session_id": "test-gpt",
401 "model": {"id": "gpt-4o", "display_name": "GPT-4o"}
402 }"#;
403
404 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
405 let session = raw.to_session_domain().expect("should create session");
406
407 assert_eq!(session.model, Model::Unknown);
408 assert_eq!(session.model_display_override.as_deref(), Some("GPT-4o"));
409 }
410
411 #[test]
412 fn test_raw_status_line_partial_data() {
413 let json = r#"{
415 "session_id": "test-partial",
416 "model": {"id": "claude-sonnet-4-20250514"}
417 }"#;
418
419 let raw: RawStatusLine = serde_json::from_str(json).unwrap();
420 let session = raw
421 .to_session_domain()
422 .expect("should create with defaults");
423
424 assert_eq!(session.id.as_str(), "test-partial");
425 assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
426 assert_eq!(session.context.total_input_tokens.as_u64(), 0);
427 }
428}