1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use serde::Deserialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum SessionStatus {
10 NeedsInput, Processing, WaitingInput, Unknown, Idle, Finished, }
17
18impl fmt::Display for SessionStatus {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::NeedsInput => write!(f, "Needs Input"),
22 Self::Processing => write!(f, "Processing"),
23 Self::WaitingInput => write!(f, "Waiting"),
24 Self::Unknown => write!(f, "Unknown"),
25 Self::Idle => write!(f, "Idle"),
26 Self::Finished => write!(f, "Finished"),
27 }
28 }
29}
30
31impl SessionStatus {
32 pub fn sort_key(&self) -> u8 {
33 match self {
34 Self::NeedsInput => 0,
35 Self::Processing => 1,
36 Self::WaitingInput => 2,
37 Self::Unknown => 3,
38 Self::Idle => 4,
39 Self::Finished => 5,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TelemetryStatus {
46 Pending,
47 Available,
48 MissingTranscript,
49 UnreadableTranscript,
50 UnsupportedTranscript,
51}
52
53impl TelemetryStatus {
54 pub fn is_available(self) -> bool {
55 matches!(self, Self::Available)
56 }
57
58 pub fn label(self) -> &'static str {
59 match self {
60 Self::Pending => "Pending",
61 Self::Available => "Available",
62 Self::MissingTranscript => "No transcript",
63 Self::UnreadableTranscript => "Unreadable transcript",
64 Self::UnsupportedTranscript => "Unsupported transcript",
65 }
66 }
67
68 pub fn short_label(self) -> &'static str {
69 match self {
70 Self::Pending => "Pending",
71 Self::Available => "Available",
72 Self::MissingTranscript => "No transcript",
73 Self::UnreadableTranscript => "Unreadable",
74 Self::UnsupportedTranscript => "Unsupported",
75 }
76 }
77}
78
79#[derive(Debug, Deserialize)]
80pub struct RawSession {
81 pub pid: u32,
82 #[serde(rename = "sessionId")]
83 pub session_id: String,
84 pub cwd: String,
85 #[serde(rename = "startedAt")]
86 pub started_at: u64,
87}
88
89#[derive(Debug, Clone)]
90pub struct ClaudeSession {
91 pub pid: u32,
92 #[allow(dead_code)]
93 pub session_id: String,
94 pub cwd: String,
95 pub project_name: String,
96 pub started_at: u64,
97 pub elapsed: Duration,
98 pub tty: String,
99 pub status: SessionStatus,
100 pub cpu_percent: f32,
101 pub cpu_history: Vec<f32>, pub mem_mb: f64,
103 pub own_input_tokens: u64,
104 pub own_output_tokens: u64,
105 pub own_cache_read_tokens: u64,
106 pub own_cache_write_tokens: u64,
107 pub subagent_input_tokens: u64,
108 pub subagent_output_tokens: u64,
109 pub subagent_cache_read_tokens: u64,
110 pub subagent_cache_write_tokens: u64,
111 pub total_input_tokens: u64,
112 pub total_output_tokens: u64,
113 pub model: String,
114 pub command_args: String,
115 pub session_name: String,
116 pub jsonl_path: Option<PathBuf>,
117 pub jsonl_offset: u64,
118 pub last_message_ts: u64,
119 pub cache_read_tokens: u64,
120 pub cache_write_tokens: u64,
121 pub cost_usd: f64,
122 pub context_tokens: u64,
123 pub context_max: u64,
124 pub prev_cost_usd: f64,
125 pub burn_rate_per_hr: f64,
126 pub subagent_count: usize,
127 pub active_subagent_count: usize,
128 pub active_subagent_jsonl_paths: Vec<PathBuf>,
129 pub subagent_rollups: HashMap<PathBuf, SubagentRollup>,
130 pub activity_history: Vec<u8>, pub files_modified: HashMap<String, u32>, pub tool_usage: HashMap<String, ToolStats>, pub worktree_id: Option<String>, pub telemetry_status: TelemetryStatus,
135 pub usage_metrics_available: bool,
136 pub cost_estimate_unverified: bool,
137 pub model_profile_source: String,
138 pub last_msg_type: String,
140 pub last_stop_reason: String,
141 pub is_waiting_for_task: bool,
142 pub pending_tool_name: Option<String>,
144 pub pending_tool_input: Option<String>, pub pending_file_path: Option<String>, pub has_file_conflict: bool, pub last_tool_error: bool,
148 pub last_error_message: Option<String>,
149 pub recent_errors: Vec<ErrorEntry>, pub total_tokens_at_edit_count: u64,
153 pub edit_event_count: u32,
155 pub baseline_tokens_per_edit: Option<f64>,
157 pub error_counts_per_window: Vec<u32>, pub current_window_errors: u32,
161 pub window_tick_counter: u32,
163 pub baseline_error_rate: Option<f64>,
165 pub file_reads_since_edit: HashMap<String, u32>,
167 pub total_error_count: u32,
169 pub decay_score: u32,
171 pub worker_origin: Option<String>,
174}
175
176#[derive(Debug, Clone)]
178pub struct ErrorEntry {
179 pub tool_name: String,
180 pub message: String,
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct ToolStats {
186 pub calls: u32,
187}
188
189#[derive(Debug, Clone, Default)]
190pub struct SubagentRollup {
191 pub jsonl_offset: u64,
192 pub input_tokens: u64,
193 pub output_tokens: u64,
194 pub cache_read_tokens: u64,
195 pub cache_write_tokens: u64,
196 pub cost_usd: f64,
197 pub model: String,
198 pub cost_estimate_unverified: bool,
199 pub usage_metrics_available: bool,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum SubagentState {
204 Active,
205 Completed,
206}
207
208#[derive(Debug, Clone)]
209pub struct SubagentBreakdown {
210 pub label: String,
211 pub state: SubagentState,
212 pub count: usize,
213 pub input_tokens: u64,
214 pub output_tokens: u64,
215 pub cache_read_tokens: u64,
216 pub cache_write_tokens: u64,
217 pub cost_usd: f64,
218 pub usage_metrics_available: bool,
219 pub cost_estimate_unverified: bool,
220}
221
222impl SubagentBreakdown {
223 pub fn total_input_tokens(&self) -> u64 {
224 self.input_tokens + self.cache_read_tokens + self.cache_write_tokens
225 }
226
227 pub fn state_label(&self) -> String {
228 match self.state {
229 SubagentState::Active => "Active".to_string(),
230 SubagentState::Completed if self.count > 1 => format!("Completed ({})", self.count),
231 SubagentState::Completed => "Completed".to_string(),
232 }
233 }
234
235 pub fn display_label(&self) -> String {
236 if self.state == SubagentState::Completed && self.label == "completed" && self.count > 1 {
237 format!("completed ({})", self.count)
238 } else {
239 self.label.clone()
240 }
241 }
242
243 pub fn format_tokens(&self) -> String {
244 if !self.usage_metrics_available {
245 return "n/a".to_string();
246 }
247 let total = self.total_input_tokens() + self.output_tokens;
248 if total == 0 {
249 return "-".to_string();
250 }
251 format_count(self.total_input_tokens()) + "/" + &format_count(self.output_tokens)
252 }
253
254 pub fn format_cost(&self) -> String {
255 if !self.usage_metrics_available {
256 return "n/a".to_string();
257 }
258 if self.cost_usd < 0.01 {
259 return "-".to_string();
260 }
261 if self.cost_usd < 1.0 {
262 format!(
263 "${:.2}{}",
264 self.cost_usd,
265 if self.cost_estimate_unverified {
266 "?"
267 } else {
268 ""
269 }
270 )
271 } else {
272 format!(
273 "${:.1}{}",
274 self.cost_usd,
275 if self.cost_estimate_unverified {
276 "?"
277 } else {
278 ""
279 }
280 )
281 }
282 }
283}
284
285impl ClaudeSession {
286 pub fn from_raw(raw: RawSession) -> Self {
287 let project_name = raw.cwd.rsplit('/').next().unwrap_or("unknown").to_string();
288
289 let now_ms = std::time::SystemTime::now()
290 .duration_since(std::time::UNIX_EPOCH)
291 .unwrap_or_default()
292 .as_millis() as u64;
293 let elapsed_ms = now_ms.saturating_sub(raw.started_at);
294 let elapsed = Duration::from_millis(elapsed_ms);
295
296 Self {
297 pid: raw.pid,
298 session_id: raw.session_id,
299 cwd: raw.cwd,
300 project_name,
301 started_at: raw.started_at,
302 elapsed,
303 tty: String::new(),
304 status: SessionStatus::Idle,
305 cpu_percent: 0.0,
306 cpu_history: Vec::new(),
307 mem_mb: 0.0,
308 own_input_tokens: 0,
309 own_output_tokens: 0,
310 own_cache_read_tokens: 0,
311 own_cache_write_tokens: 0,
312 subagent_input_tokens: 0,
313 subagent_output_tokens: 0,
314 subagent_cache_read_tokens: 0,
315 subagent_cache_write_tokens: 0,
316 total_input_tokens: 0,
317 total_output_tokens: 0,
318 model: String::new(),
319 command_args: String::new(),
320 session_name: String::new(),
321 jsonl_path: None,
322 jsonl_offset: 0,
323 last_message_ts: 0,
324 cache_read_tokens: 0,
325 cache_write_tokens: 0,
326 cost_usd: 0.0,
327 context_tokens: 0,
328 context_max: 0,
329 prev_cost_usd: 0.0,
330 burn_rate_per_hr: 0.0,
331 subagent_count: 0,
332 active_subagent_count: 0,
333 active_subagent_jsonl_paths: Vec::new(),
334 subagent_rollups: HashMap::new(),
335 activity_history: Vec::new(),
336 files_modified: HashMap::new(),
337 tool_usage: HashMap::new(),
338 worktree_id: None,
339 telemetry_status: TelemetryStatus::Pending,
340 usage_metrics_available: false,
341 cost_estimate_unverified: false,
342 model_profile_source: "built-in".into(),
343 last_msg_type: String::new(),
344 last_stop_reason: String::new(),
345 is_waiting_for_task: false,
346 pending_tool_name: None,
347 pending_tool_input: None,
348 pending_file_path: None,
349 has_file_conflict: false,
350 last_tool_error: false,
351 last_error_message: None,
352 recent_errors: Vec::new(),
353 total_tokens_at_edit_count: 0,
354 edit_event_count: 0,
355 baseline_tokens_per_edit: None,
356 error_counts_per_window: Vec::new(),
357 current_window_errors: 0,
358 window_tick_counter: 0,
359 baseline_error_rate: None,
360 file_reads_since_edit: HashMap::new(),
361 total_error_count: 0,
362 decay_score: 0,
363 worker_origin: None,
364 }
365 }
366
367 pub fn record_activity(&mut self) {
370 let level = match self.status {
371 SessionStatus::Processing => 7,
372 SessionStatus::NeedsInput => 4,
373 SessionStatus::WaitingInput => 2,
374 SessionStatus::Unknown => 2,
375 SessionStatus::Idle => 1,
376 SessionStatus::Finished => 0,
377 };
378 self.activity_history.push(level);
379 if self.activity_history.len() > 15 {
380 self.activity_history.remove(0);
381 }
382
383 self.window_tick_counter += 1;
385 if self.window_tick_counter >= 5 {
386 self.error_counts_per_window
387 .push(self.current_window_errors);
388 if self.error_counts_per_window.len() > 10 {
389 self.error_counts_per_window.remove(0);
390 }
391 if self.baseline_error_rate.is_none() && self.error_counts_per_window.len() >= 3 {
393 let sum: u32 = self.error_counts_per_window.iter().sum();
394 self.baseline_error_rate =
395 Some(sum as f64 / self.error_counts_per_window.len() as f64);
396 }
397 self.current_window_errors = 0;
398 self.window_tick_counter = 0;
399 }
400 }
401
402 pub fn format_sparkline(&self) -> String {
404 const BLOCKS: &[char] = &[
405 ' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}',
406 '\u{2587}', '\u{2588}',
407 ];
408 if self.activity_history.is_empty() {
409 return String::from("-");
410 }
411 self.activity_history
412 .iter()
413 .map(|&level| BLOCKS[level.min(8) as usize])
414 .collect()
415 }
416
417 pub fn display_name(&self) -> &str {
418 if !self.session_name.is_empty() {
419 &self.session_name
420 } else {
421 &self.project_name
422 }
423 }
424
425 pub fn is_remote(&self) -> bool {
427 self.worker_origin.is_some()
428 }
429
430 #[allow(dead_code)]
432 pub fn from_remote_json(worker_id: &str, json: &serde_json::Value) -> Option<Self> {
433 let pid = json.get("pid")?.as_u64()? as u32;
434 let project = json.get("project")?.as_str()?;
435 let status_str = json
436 .get("status")
437 .and_then(|v| v.as_str())
438 .unwrap_or("Unknown");
439 let status = match status_str {
440 "Needs Input" => SessionStatus::NeedsInput,
441 "Processing" => SessionStatus::Processing,
442 "Waiting" => SessionStatus::WaitingInput,
443 "Idle" => SessionStatus::Idle,
444 "Finished" => SessionStatus::Finished,
445 _ => SessionStatus::Unknown,
446 };
447
448 let elapsed_secs = json
449 .get("elapsed_secs")
450 .and_then(|v| v.as_u64())
451 .unwrap_or(0);
452 let now_ms = std::time::SystemTime::now()
453 .duration_since(std::time::UNIX_EPOCH)
454 .unwrap_or_default()
455 .as_millis() as u64;
456
457 let mut session = Self::from_raw(RawSession {
458 pid,
459 session_id: format!("remote-{worker_id}-{pid}"),
460 cwd: project.to_string(),
461 started_at: now_ms.saturating_sub(elapsed_secs * 1000),
462 });
463 session.status = status;
464 session.worker_origin = Some(worker_id.to_string());
465 session.project_name = format!("[{worker_id}] {project}");
466
467 if let Some(cost) = json.get("cost_usd").and_then(|v| v.as_f64()) {
469 session.cost_usd = cost;
470 session.usage_metrics_available = true;
471 }
472 if let Some(burn) = json.get("burn_rate_per_hr").and_then(|v| v.as_f64()) {
473 session.burn_rate_per_hr = burn;
474 }
475 if let Some(ctx) = json.get("context_pct").and_then(|v| v.as_f64()) {
476 session.context_max = 200_000; session.context_tokens = ((ctx / 100.0) * session.context_max as f64) as u64;
479 }
480 if let Some(t_in) = json.get("tokens_in").and_then(|v| v.as_u64()) {
481 session.total_input_tokens = t_in;
482 session.usage_metrics_available = true;
483 }
484 if let Some(t_out) = json.get("tokens_out").and_then(|v| v.as_u64()) {
485 session.total_output_tokens = t_out;
486 }
487 if let Some(model) = json.get("model").and_then(|v| v.as_str()) {
488 session.model = model.to_string();
489 }
490 if let Some(subs) = json.get("subagents").and_then(|v| v.as_u64()) {
491 session.subagent_count = subs as usize;
492 }
493 if let Some(decay) = json.get("decay_score").and_then(|v| v.as_u64()) {
494 session.decay_score = decay as u32;
495 }
496
497 Some(session)
498 }
499
500 pub fn format_subagent_summary(&self) -> String {
501 if self.subagent_count == 0 {
502 return "0".to_string();
503 }
504 if self.active_subagent_count == 0 || self.active_subagent_count == self.subagent_count {
505 return self.subagent_count.to_string();
506 }
507 format!(
508 "{} total ({} active)",
509 self.subagent_count, self.active_subagent_count
510 )
511 }
512
513 pub fn subagent_breakdown(&self) -> Vec<SubagentBreakdown> {
514 if self.subagent_rollups.is_empty() {
515 return Vec::new();
516 }
517
518 let active_paths: HashSet<&PathBuf> = self.active_subagent_jsonl_paths.iter().collect();
519 let mut active_rows = Vec::new();
520 let mut completed_rows = Vec::new();
521
522 for (path, rollup) in &self.subagent_rollups {
523 let row = SubagentBreakdown {
524 label: subagent_label(path),
525 state: if active_paths.contains(path) {
526 SubagentState::Active
527 } else {
528 SubagentState::Completed
529 },
530 count: 1,
531 input_tokens: rollup.input_tokens,
532 output_tokens: rollup.output_tokens,
533 cache_read_tokens: rollup.cache_read_tokens,
534 cache_write_tokens: rollup.cache_write_tokens,
535 cost_usd: rollup.cost_usd,
536 usage_metrics_available: rollup.usage_metrics_available,
537 cost_estimate_unverified: rollup.cost_estimate_unverified,
538 };
539
540 if row.state == SubagentState::Active {
541 active_rows.push(row);
542 } else {
543 completed_rows.push(row);
544 }
545 }
546
547 active_rows.sort_by(|a, b| a.label.cmp(&b.label));
548
549 let mut rows = Vec::new();
550 if !completed_rows.is_empty() {
551 let mut aggregate = SubagentBreakdown {
552 label: "completed".to_string(),
553 state: SubagentState::Completed,
554 count: completed_rows.len(),
555 input_tokens: 0,
556 output_tokens: 0,
557 cache_read_tokens: 0,
558 cache_write_tokens: 0,
559 cost_usd: 0.0,
560 usage_metrics_available: false,
561 cost_estimate_unverified: false,
562 };
563
564 for row in completed_rows {
565 aggregate.input_tokens += row.input_tokens;
566 aggregate.output_tokens += row.output_tokens;
567 aggregate.cache_read_tokens += row.cache_read_tokens;
568 aggregate.cache_write_tokens += row.cache_write_tokens;
569 aggregate.cost_usd += row.cost_usd;
570 aggregate.usage_metrics_available |= row.usage_metrics_available;
571 aggregate.cost_estimate_unverified |= row.cost_estimate_unverified;
572 }
573
574 rows.push(aggregate);
575 }
576
577 rows.extend(active_rows);
578 rows
579 }
580
581 pub fn format_elapsed(&self) -> String {
582 let secs = self.elapsed.as_secs();
583 let h = secs / 3600;
584 let m = (secs % 3600) / 60;
585 let s = secs % 60;
586 if h > 0 {
587 format!("{h:02}:{m:02}:{s:02}")
588 } else {
589 format!("{m:02}:{s:02}")
590 }
591 }
592
593 pub fn format_tokens(&self) -> String {
594 if !self.usage_metrics_available {
595 return "n/a".to_string();
596 }
597 let total = self.total_input_tokens + self.total_output_tokens;
598 if total == 0 {
599 return String::from("-");
600 }
601 format_count(self.total_input_tokens) + "/" + &format_count(self.total_output_tokens)
602 }
603
604 pub fn format_mem(&self) -> String {
605 if self.mem_mb < 1.0 {
606 return String::from("-");
607 }
608 format!("{:.0}M", self.mem_mb)
609 }
610
611 pub fn format_cost(&self) -> String {
612 if !self.usage_metrics_available {
613 return "n/a".to_string();
614 }
615 if self.cost_usd < 0.01 {
616 return String::from("-");
617 }
618 if self.cost_usd < 1.0 {
619 format!(
620 "${:.2}{}",
621 self.cost_usd,
622 if self.cost_estimate_unverified {
623 "?"
624 } else {
625 ""
626 }
627 )
628 } else {
629 format!(
630 "${:.1}{}",
631 self.cost_usd,
632 if self.cost_estimate_unverified {
633 "?"
634 } else {
635 ""
636 }
637 )
638 }
639 }
640
641 pub fn context_percent(&self) -> f64 {
642 if !self.usage_metrics_available {
643 return 0.0;
644 }
645 if self.context_max == 0 || self.context_tokens == 0 {
646 return 0.0;
647 }
648 (self.context_tokens as f64 / self.context_max as f64) * 100.0
649 }
650
651 pub fn format_context(&self) -> String {
653 if !self.usage_metrics_available {
654 return "n/a".to_string();
655 }
656 if self.context_tokens == 0 {
657 return String::from("-");
658 }
659 let pct = self.context_percent();
660 format!("{}%", pct as u32)
661 }
662
663 pub fn format_context_bar(&self, width: usize) -> String {
665 if !self.usage_metrics_available {
666 return "n/a".to_string();
667 }
668 let pct = self.context_percent();
669 if pct == 0.0 {
670 return String::from("-");
671 }
672 let filled = ((pct / 100.0) * width as f64).round() as usize;
673 let empty = width.saturating_sub(filled);
674 format!(
675 "{}{} {}%",
676 "█".repeat(filled),
677 "░".repeat(empty),
678 pct as u32
679 )
680 }
681
682 pub fn to_json_value(&self) -> serde_json::Value {
684 let cost_usd = if self.usage_metrics_available {
685 serde_json::json!((self.cost_usd * 100.0).round() / 100.0)
686 } else {
687 serde_json::Value::Null
688 };
689 let burn_rate = if self.usage_metrics_available {
690 serde_json::json!((self.burn_rate_per_hr * 100.0).round() / 100.0)
691 } else {
692 serde_json::Value::Null
693 };
694 let context_pct = if self.usage_metrics_available {
695 serde_json::json!((self.context_percent() * 100.0).round() / 100.0)
696 } else {
697 serde_json::Value::Null
698 };
699 let tokens_in = if self.usage_metrics_available {
700 serde_json::json!(self.total_input_tokens)
701 } else {
702 serde_json::Value::Null
703 };
704 let tokens_out = if self.usage_metrics_available {
705 serde_json::json!(self.total_output_tokens)
706 } else {
707 serde_json::Value::Null
708 };
709
710 serde_json::json!({
711 "pid": self.pid,
712 "project": self.display_name(),
713 "status": self.status.to_string(),
714 "telemetry": {
715 "state": self.telemetry_status.label(),
716 "usage_metrics_available": self.usage_metrics_available,
717 },
718 "estimate": {
719 "verified": !self.cost_estimate_unverified,
720 "profile_source": self.model_profile_source,
721 },
722 "context_pct": context_pct,
723 "cost_usd": cost_usd,
724 "burn_rate_per_hr": burn_rate,
725 "elapsed_secs": self.elapsed.as_secs(),
726 "cpu": self.cpu_percent,
727 "mem_mb": (self.mem_mb * 100.0).round() / 100.0,
728 "tokens_in": tokens_in,
729 "tokens_out": tokens_out,
730 "subagents": self.subagent_count,
731 "active_subagents": self.active_subagent_count,
732 "subagent_breakdown": self.subagent_breakdown().into_iter().map(|row| {
733 serde_json::json!({
734 "label": row.display_label(),
735 "state": row.state_label(),
736 "count": row.count,
737 "tokens_in": if row.usage_metrics_available {
738 serde_json::json!(row.total_input_tokens())
739 } else {
740 serde_json::Value::Null
741 },
742 "tokens_out": if row.usage_metrics_available {
743 serde_json::json!(row.output_tokens)
744 } else {
745 serde_json::Value::Null
746 },
747 "cost_usd": if row.usage_metrics_available {
748 serde_json::json!((row.cost_usd * 100.0).round() / 100.0)
749 } else {
750 serde_json::Value::Null
751 },
752 })
753 }).collect::<Vec<_>>(),
754 "decay_score": if self.usage_metrics_available { serde_json::json!(self.decay_score) } else { serde_json::Value::Null },
755 "last_error": self.last_error_message,
756 "recent_errors": self.recent_errors.iter().map(|e| {
757 serde_json::json!({
758 "tool": e.tool_name,
759 "message": e.message,
760 })
761 }).collect::<Vec<_>>(),
762 "files_modified": self.files_modified,
763 "tool_usage": self.tool_usage.iter().map(|(k, v)| {
764 (k.clone(), serde_json::json!({"calls": v.calls}))
765 }).collect::<serde_json::Map<String, serde_json::Value>>(),
766 "worker_origin": self.worker_origin,
767 })
768 }
769
770 pub fn format_burn_rate(&self) -> String {
771 if !self.usage_metrics_available {
772 return "n/a".to_string();
773 }
774 if self.burn_rate_per_hr < 0.01 {
775 return String::from("-");
776 }
777 if self.burn_rate_per_hr < 1.0 {
778 format!(
779 "${:.2}/h{}",
780 self.burn_rate_per_hr,
781 if self.cost_estimate_unverified {
782 "?"
783 } else {
784 ""
785 }
786 )
787 } else {
788 format!(
789 "${:.1}/h{}",
790 self.burn_rate_per_hr,
791 if self.cost_estimate_unverified {
792 "?"
793 } else {
794 ""
795 }
796 )
797 }
798 }
799
800 pub fn telemetry_label(&self) -> &'static str {
801 self.telemetry_status.label()
802 }
803
804 pub fn has_usage_metrics(&self) -> bool {
805 self.usage_metrics_available
806 }
807}
808
809pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
812 if s.len() <= max_bytes {
813 return s;
814 }
815 let mut end = max_bytes;
816 while end > 0 && !s.is_char_boundary(end) {
817 end -= 1;
818 }
819 &s[..end]
820}
821
822fn format_count(n: u64) -> String {
823 if n >= 1_000_000 {
824 format!("{:.1}M", n as f64 / 1_000_000.0)
825 } else if n >= 1_000 {
826 format!("{:.1}k", n as f64 / 1_000.0)
827 } else {
828 n.to_string()
829 }
830}
831
832fn subagent_label(path: &Path) -> String {
833 let components: Vec<String> = path
834 .components()
835 .map(|component| component.as_os_str().to_string_lossy().to_string())
836 .collect();
837
838 if let Some(tasks_idx) = components.iter().position(|component| component == "tasks") {
839 let relative = &components[tasks_idx + 1..];
840 if !relative.is_empty() {
841 let mut label = relative.join("/");
842 if let Some(stripped) = label.strip_suffix(".jsonl") {
843 label = stripped.to_string();
844 }
845 return label;
846 }
847 }
848
849 path.file_stem()
850 .and_then(|stem| stem.to_str())
851 .unwrap_or("subagent")
852 .to_string()
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858
859 fn make_session() -> ClaudeSession {
860 ClaudeSession::from_raw(RawSession {
861 pid: 1,
862 session_id: "session-1".into(),
863 cwd: "/tmp/project".into(),
864 started_at: 0,
865 })
866 }
867
868 #[test]
869 fn subagent_breakdown_groups_completed_and_lists_active_rows() {
870 let mut session = make_session();
871 let completed = PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl");
872 let active =
873 PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/nested/agent-2.jsonl");
874
875 session.active_subagent_jsonl_paths = vec![active.clone()];
876 session.subagent_rollups.insert(
877 completed,
878 SubagentRollup {
879 input_tokens: 10_000,
880 output_tokens: 2_000,
881 cost_usd: 0.25,
882 usage_metrics_available: true,
883 ..SubagentRollup::default()
884 },
885 );
886 session.subagent_rollups.insert(
887 active,
888 SubagentRollup {
889 input_tokens: 40_000,
890 output_tokens: 8_000,
891 cost_usd: 1.5,
892 usage_metrics_available: true,
893 ..SubagentRollup::default()
894 },
895 );
896
897 let rows = session.subagent_breakdown();
898 assert_eq!(rows.len(), 2);
899 assert_eq!(rows[0].display_label(), "completed");
900 assert_eq!(rows[0].state, SubagentState::Completed);
901 assert_eq!(rows[0].count, 1);
902 assert_eq!(rows[0].format_tokens(), "10.0k/2.0k");
903 assert_eq!(rows[1].display_label(), "nested/agent-2");
904 assert_eq!(rows[1].state, SubagentState::Active);
905 assert_eq!(rows[1].format_cost(), "$1.5");
906 }
907
908 #[test]
909 fn subagent_breakdown_collapses_multiple_completed_rows() {
910 let mut session = make_session();
911
912 for name in ["agent-1.jsonl", "agent-2.jsonl"] {
913 let path = PathBuf::from(format!("/tmp/claude-1/-tmp-project/session-1/tasks/{name}"));
914 session.subagent_rollups.insert(
915 path,
916 SubagentRollup {
917 input_tokens: 10_000,
918 output_tokens: 1_000,
919 cost_usd: 0.2,
920 usage_metrics_available: true,
921 ..SubagentRollup::default()
922 },
923 );
924 }
925
926 let rows = session.subagent_breakdown();
927 assert_eq!(rows.len(), 1);
928 assert_eq!(rows[0].display_label(), "completed (2)");
929 assert_eq!(rows[0].count, 2);
930 assert_eq!(rows[0].format_tokens(), "20.0k/2.0k");
931 }
932
933 #[test]
936 fn error_window_flush() {
937 let mut s = make_session();
938 s.current_window_errors = 3;
939 for _ in 0..5 {
941 s.record_activity();
942 }
943 assert_eq!(s.error_counts_per_window.len(), 1);
944 assert_eq!(s.error_counts_per_window[0], 3);
945 assert_eq!(s.current_window_errors, 0);
946 assert_eq!(s.window_tick_counter, 0);
947 }
948
949 #[test]
950 fn baseline_error_rate_freezes() {
951 let mut s = make_session();
952 for errors in [2, 3, 4] {
954 s.current_window_errors = errors;
955 for _ in 0..5 {
956 s.record_activity();
957 }
958 }
959 assert_eq!(s.error_counts_per_window.len(), 3);
960 let baseline = s.baseline_error_rate.expect("baseline should be set");
961 assert!((baseline - 3.0).abs() < 0.01);
963
964 s.current_window_errors = 10;
966 for _ in 0..5 {
967 s.record_activity();
968 }
969 assert_eq!(s.baseline_error_rate.unwrap(), baseline);
970 }
971
972 #[test]
975 fn local_session_is_not_remote() {
976 let s = make_session();
977 assert!(!s.is_remote());
978 assert!(s.worker_origin.is_none());
979 }
980
981 #[test]
982 fn from_remote_json_parses_basic_fields() {
983 let json = serde_json::json!({
984 "pid": 42,
985 "project": "backend",
986 "status": "Processing",
987 "cost_usd": 1.23,
988 "elapsed_secs": 600,
989 "tokens_in": 50000,
990 "tokens_out": 10000,
991 });
992 let session = ClaudeSession::from_remote_json("macbook-02", &json).unwrap();
993 assert!(session.is_remote());
994 assert_eq!(session.worker_origin.as_deref(), Some("macbook-02"));
995 assert_eq!(session.pid, 42);
996 assert_eq!(session.project_name, "[macbook-02] backend");
997 assert_eq!(session.status, SessionStatus::Processing);
998 assert!((session.cost_usd - 1.23).abs() < 0.01);
999 assert_eq!(session.total_input_tokens, 50000);
1000 assert_eq!(session.total_output_tokens, 10000);
1001 assert!(session.usage_metrics_available);
1002 }
1003
1004 #[test]
1005 fn from_remote_json_handles_all_statuses() {
1006 for (label, expected) in [
1007 ("Needs Input", SessionStatus::NeedsInput),
1008 ("Processing", SessionStatus::Processing),
1009 ("Waiting", SessionStatus::WaitingInput),
1010 ("Idle", SessionStatus::Idle),
1011 ("Finished", SessionStatus::Finished),
1012 ("SomethingElse", SessionStatus::Unknown),
1013 ] {
1014 let json = serde_json::json!({"pid": 1, "project": "p", "status": label});
1015 let session = ClaudeSession::from_remote_json("w", &json).unwrap();
1016 assert_eq!(session.status, expected, "status mismatch for {label}");
1017 }
1018 }
1019
1020 #[test]
1021 fn from_remote_json_returns_none_on_missing_fields() {
1022 let json = serde_json::json!({"project": "x", "status": "Idle"});
1024 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1025
1026 let json = serde_json::json!({"pid": 1, "status": "Idle"});
1028 assert!(ClaudeSession::from_remote_json("w", &json).is_none());
1029 }
1030
1031 #[test]
1032 fn remote_session_display_name_shows_worker_prefix() {
1033 let json = serde_json::json!({"pid": 1, "project": "api-server", "status": "Idle"});
1034 let session = ClaudeSession::from_remote_json("laptop-01", &json).unwrap();
1035 assert_eq!(session.display_name(), "[laptop-01] api-server");
1036 }
1037
1038 #[test]
1039 fn remote_session_json_includes_worker_origin() {
1040 let json = serde_json::json!({"pid": 1, "project": "test", "status": "Idle"});
1041 let session = ClaudeSession::from_remote_json("remote-w", &json).unwrap();
1042 let output = session.to_json_value();
1043 assert_eq!(
1044 output.get("worker_origin").and_then(|v| v.as_str()),
1045 Some("remote-w")
1046 );
1047 }
1048}