1use crate::hook::is_interactive_tool;
4use crate::{AgentType, ContextUsage, HookEventType, Model, Money, TokenCount};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8use std::collections::VecDeque;
9use std::fmt;
10use std::path::{Path, PathBuf};
11use tracing::debug;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct SessionId(String);
24
25pub const PENDING_SESSION_PREFIX: &str = "pending-";
27
28impl SessionId {
29 pub fn new(id: impl Into<String>) -> Self {
34 Self(id.into())
35 }
36
37 pub fn pending_from_pid(pid: u32) -> Self {
44 Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
45 }
46
47 #[must_use]
49 pub fn is_pending(&self) -> bool {
50 self.0.starts_with(PENDING_SESSION_PREFIX)
51 }
52
53 pub fn pending_pid(&self) -> Option<u32> {
57 if !self.is_pending() {
58 return None;
59 }
60 self.0
61 .strip_prefix(PENDING_SESSION_PREFIX)
62 .and_then(|s| s.parse().ok())
63 }
64
65 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69
70 #[must_use]
74 pub fn short(&self) -> &str {
75 self.0.get(..8).unwrap_or(&self.0)
76 }
77}
78
79impl fmt::Display for SessionId {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 write!(f, "{}", self.0)
82 }
83}
84
85impl From<String> for SessionId {
86 fn from(s: String) -> Self {
87 Self(s)
88 }
89}
90
91impl From<&str> for SessionId {
92 fn from(s: &str) -> Self {
93 Self(s.to_string())
94 }
95}
96
97impl AsRef<str> for SessionId {
98 fn as_ref(&self) -> &str {
99 &self.0
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[serde(transparent)]
109pub struct ToolUseId(String);
110
111impl ToolUseId {
112 pub fn new(id: impl Into<String>) -> Self {
113 Self(id.into())
114 }
115
116 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121impl fmt::Display for ToolUseId {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 write!(f, "{}", self.0)
124 }
125}
126
127impl From<String> for ToolUseId {
128 fn from(s: String) -> Self {
129 Self(s)
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
137#[serde(transparent)]
138pub struct TranscriptPath(PathBuf);
139
140impl TranscriptPath {
141 pub fn new(path: impl Into<PathBuf>) -> Self {
142 Self(path.into())
143 }
144
145 pub fn as_path(&self) -> &Path {
146 &self.0
147 }
148
149 pub fn filename(&self) -> Option<&str> {
151 self.0.file_name().and_then(|n| n.to_str())
152 }
153}
154
155impl fmt::Display for TranscriptPath {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 write!(f, "{}", self.0.display())
158 }
159}
160
161impl AsRef<Path> for TranscriptPath {
162 fn as_ref(&self) -> &Path {
163 &self.0
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum SessionStatus {
180 #[default]
183 Idle,
184
185 Working,
188
189 AttentionNeeded,
192}
193
194impl SessionStatus {
195 #[must_use]
197 pub fn label(&self) -> &'static str {
198 match self {
199 Self::Idle => "idle",
200 Self::Working => "working",
201 Self::AttentionNeeded => "needs input",
202 }
203 }
204
205 #[must_use]
207 pub fn icon(&self) -> &'static str {
208 match self {
209 Self::Idle => "-",
210 Self::Working => ">",
211 Self::AttentionNeeded => "!",
212 }
213 }
214
215 #[must_use]
217 pub fn should_blink(&self) -> bool {
218 matches!(self, Self::AttentionNeeded)
219 }
220
221 #[must_use]
223 pub fn is_active(&self) -> bool {
224 matches!(self, Self::Working)
225 }
226
227 #[must_use]
229 pub fn needs_attention(&self) -> bool {
230 matches!(self, Self::AttentionNeeded)
231 }
232}
233
234impl fmt::Display for SessionStatus {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 match self {
237 Self::Idle => write!(f, "Idle"),
238 Self::Working => write!(f, "Working"),
239 Self::AttentionNeeded => write!(f, "Needs Input"),
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ActivityDetail {
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub tool_name: Option<String>,
257 pub started_at: DateTime<Utc>,
259 #[serde(skip_serializing_if = "Option::is_none")]
261 pub context: Option<String>,
262}
263
264impl ActivityDetail {
265 pub fn new(tool_name: &str) -> Self {
267 Self {
268 tool_name: Some(tool_name.to_string()),
269 started_at: Utc::now(),
270 context: None,
271 }
272 }
273
274 pub fn with_context(context: &str) -> Self {
276 Self {
277 tool_name: None,
278 started_at: Utc::now(),
279 context: Some(context.to_string()),
280 }
281 }
282
283 pub fn thinking() -> Self {
285 Self::with_context("Thinking")
286 }
287
288 pub fn duration(&self) -> chrono::Duration {
290 Utc::now().signed_duration_since(self.started_at)
291 }
292
293 #[must_use]
297 pub fn display(&self) -> Cow<'_, str> {
298 if let Some(ref tool) = self.tool_name {
299 Cow::Borrowed(tool)
300 } else if let Some(ref ctx) = self.context {
301 Cow::Borrowed(ctx)
302 } else {
303 Cow::Borrowed("Unknown")
304 }
305 }
306}
307
308impl Default for ActivityDetail {
309 fn default() -> Self {
310 Self::thinking()
311 }
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
322pub struct SessionDuration {
323 total_ms: u64,
325 api_ms: u64,
327}
328
329impl SessionDuration {
330 pub fn new(total_ms: u64, api_ms: u64) -> Self {
332 Self { total_ms, api_ms }
333 }
334
335 pub fn from_total_ms(total_ms: u64) -> Self {
337 Self {
338 total_ms,
339 api_ms: 0,
340 }
341 }
342
343 pub fn total_ms(&self) -> u64 {
345 self.total_ms
346 }
347
348 pub fn api_ms(&self) -> u64 {
350 self.api_ms
351 }
352
353 pub fn total_seconds(&self) -> f64 {
355 self.total_ms as f64 / 1000.0
356 }
357
358 pub fn overhead_ms(&self) -> u64 {
360 self.total_ms.saturating_sub(self.api_ms)
361 }
362
363 pub fn format(&self) -> String {
367 let secs = self.total_ms / 1000;
368 if secs < 60 {
369 format!("{secs}s")
370 } else if secs < 3600 {
371 let mins = secs / 60;
372 let remaining_secs = secs % 60;
373 if remaining_secs == 0 {
374 format!("{mins}m")
375 } else {
376 format!("{mins}m {remaining_secs}s")
377 }
378 } else {
379 let hours = secs / 3600;
380 let remaining_mins = (secs % 3600) / 60;
381 if remaining_mins == 0 {
382 format!("{hours}h")
383 } else {
384 format!("{hours}h {remaining_mins}m")
385 }
386 }
387 }
388
389 pub fn format_compact(&self) -> String {
391 let secs = self.total_ms / 1000;
392 if secs < 60 {
393 format!("{secs}s")
394 } else if secs < 3600 {
395 let mins = secs / 60;
396 format!("{mins}m")
397 } else {
398 let hours = secs / 3600;
399 format!("{hours}h")
400 }
401 }
402}
403
404impl fmt::Display for SessionDuration {
405 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406 write!(f, "{}", self.format())
407 }
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
414pub struct LinesChanged {
415 pub added: u64,
417 pub removed: u64,
419}
420
421impl LinesChanged {
422 pub fn new(added: u64, removed: u64) -> Self {
424 Self { added, removed }
425 }
426
427 pub fn net(&self) -> i64 {
429 self.added as i64 - self.removed as i64
430 }
431
432 pub fn churn(&self) -> u64 {
434 self.added.saturating_add(self.removed)
435 }
436
437 pub fn is_empty(&self) -> bool {
439 self.added == 0 && self.removed == 0
440 }
441
442 pub fn format(&self) -> String {
444 format!("+{} -{}", self.added, self.removed)
445 }
446
447 pub fn format_net(&self) -> String {
449 let net = self.net();
450 if net >= 0 {
451 format!("+{net}")
452 } else {
453 format!("{net}")
454 }
455 }
456}
457
458impl fmt::Display for LinesChanged {
459 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
460 write!(f, "{}", self.format())
461 }
462}
463
464#[derive(Debug, Clone, Default)]
474pub struct StatusLineData {
475 pub session_id: String,
477 pub model_id: String,
479 pub model_display_name: Option<String>,
481 pub cost_usd: f64,
483 pub total_duration_ms: u64,
485 pub api_duration_ms: u64,
487 pub lines_added: u64,
489 pub lines_removed: u64,
491 pub total_input_tokens: u64,
493 pub total_output_tokens: u64,
495 pub context_window_size: u32,
497 pub current_input_tokens: u64,
499 pub current_output_tokens: u64,
501 pub cache_creation_tokens: u64,
503 pub cache_read_tokens: u64,
505 pub cwd: Option<String>,
507 pub version: Option<String>,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct SessionDomain {
523 pub id: SessionId,
525
526 pub agent_type: AgentType,
528
529 pub model: Model,
531
532 #[serde(skip_serializing_if = "Option::is_none")]
536 pub model_display_override: Option<String>,
537
538 pub status: SessionStatus,
540
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub current_activity: Option<ActivityDetail>,
544
545 pub context: ContextUsage,
547
548 pub cost: Money,
550
551 pub duration: SessionDuration,
553
554 pub lines_changed: LinesChanged,
556
557 pub started_at: DateTime<Utc>,
559
560 pub last_activity: DateTime<Utc>,
562
563 #[serde(skip_serializing_if = "Option::is_none")]
565 pub working_directory: Option<String>,
566
567 #[serde(skip_serializing_if = "Option::is_none")]
569 pub claude_code_version: Option<String>,
570
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub tmux_pane: Option<String>,
574}
575
576impl SessionDomain {
577 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
579 let now = Utc::now();
580 Self {
581 id,
582 agent_type,
583 model,
584 model_display_override: None,
585 status: SessionStatus::Idle,
586 current_activity: None,
587 context: ContextUsage::new(model.context_window_size()),
588 cost: Money::zero(),
589 duration: SessionDuration::default(),
590 lines_changed: LinesChanged::default(),
591 started_at: now,
592 last_activity: now,
593 working_directory: None,
594 claude_code_version: None,
595 tmux_pane: None,
596 }
597 }
598
599 pub fn from_status_line(data: &StatusLineData) -> Self {
601 use crate::model::derive_display_name;
602
603 let model = Model::from_id(&data.model_id);
604
605 let mut session = Self::new(
606 SessionId::new(&data.session_id),
607 AgentType::GeneralPurpose, model,
609 );
610
611 if model.is_unknown() && !data.model_id.is_empty() {
614 session.model_display_override = Some(
615 data.model_display_name
616 .clone()
617 .unwrap_or_else(|| derive_display_name(&data.model_id)),
618 );
619 }
620
621 session.cost = Money::from_usd(data.cost_usd);
622 session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
623 session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
624 session.context = ContextUsage {
625 total_input_tokens: TokenCount::new(data.total_input_tokens),
626 total_output_tokens: TokenCount::new(data.total_output_tokens),
627 context_window_size: data.context_window_size,
628 current_input_tokens: TokenCount::new(data.current_input_tokens),
629 current_output_tokens: TokenCount::new(data.current_output_tokens),
630 cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
631 cache_read_tokens: TokenCount::new(data.cache_read_tokens),
632 };
633 session.working_directory = data.cwd.clone();
634 session.claude_code_version = data.version.clone();
635 session.last_activity = Utc::now();
636
637 session
638 }
639
640 pub fn update_from_status_line(&mut self, data: &StatusLineData) {
645 self.cost = Money::from_usd(data.cost_usd);
646 self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
647 self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
648 self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
649 self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
650 self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
651 self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
652 self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
653 self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
654 self.last_activity = Utc::now();
655
656 if self.status != SessionStatus::AttentionNeeded {
659 self.status = SessionStatus::Working;
660 }
661 }
662
663 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
665 self.last_activity = Utc::now();
666
667 match event_type {
668 HookEventType::PreToolUse => {
669 if let Some(name) = tool_name {
670 if is_interactive_tool(name) {
671 self.status = SessionStatus::AttentionNeeded;
672 self.current_activity = Some(ActivityDetail::new(name));
673 } else {
674 self.status = SessionStatus::Working;
675 self.current_activity = Some(ActivityDetail::new(name));
676 }
677 }
678 }
679 HookEventType::PostToolUse | HookEventType::PostToolUseFailure => {
680 self.status = SessionStatus::Working;
681 self.current_activity = Some(ActivityDetail::thinking());
682 }
683 HookEventType::UserPromptSubmit => {
684 self.status = SessionStatus::Working;
685 self.current_activity = None;
686 }
687 HookEventType::Stop => {
688 self.status = SessionStatus::Idle;
689 self.current_activity = None;
690 }
691 HookEventType::SessionStart => {
692 self.status = SessionStatus::Idle;
693 self.current_activity = None;
694 }
695 HookEventType::SessionEnd => {
696 self.status = SessionStatus::Idle;
698 self.current_activity = None;
699 }
700 HookEventType::PreCompact => {
701 self.status = SessionStatus::Working;
702 self.current_activity = Some(ActivityDetail::with_context("Compacting"));
703 }
704 HookEventType::Setup => {
705 self.status = SessionStatus::Working;
706 self.current_activity = Some(ActivityDetail::with_context("Setup"));
707 }
708 HookEventType::Notification => {
709 }
712 HookEventType::SubagentStart | HookEventType::SubagentStop => {
713 self.status = SessionStatus::Working;
715 }
716 }
717 }
718
719 pub fn apply_notification(&mut self, notification_type: Option<&str>) {
721 self.last_activity = Utc::now();
722
723 match notification_type {
724 Some("permission_prompt") => {
725 self.status = SessionStatus::AttentionNeeded;
726 self.current_activity = Some(ActivityDetail::with_context("Permission"));
727 }
728 Some("idle_prompt") => {
729 self.status = SessionStatus::Idle;
730 self.current_activity = None;
731 }
732 Some("elicitation_dialog") => {
733 self.status = SessionStatus::AttentionNeeded;
734 self.current_activity = Some(ActivityDetail::with_context("MCP Input"));
735 }
736 _ => {
737 }
739 }
740 }
741
742 pub fn age(&self) -> chrono::Duration {
744 Utc::now().signed_duration_since(self.started_at)
745 }
746
747 pub fn time_since_activity(&self) -> chrono::Duration {
749 Utc::now().signed_duration_since(self.last_activity)
750 }
751
752 pub fn needs_context_attention(&self) -> bool {
754 self.context.is_warning() || self.context.is_critical()
755 }
756}
757
758impl Default for SessionDomain {
759 fn default() -> Self {
760 Self::new(
761 SessionId::new("unknown"),
762 AgentType::default(),
763 Model::default(),
764 )
765 }
766}
767
768#[derive(Debug, Clone)]
774pub struct ToolUsageRecord {
775 pub tool_name: String,
777 pub tool_use_id: Option<ToolUseId>,
779 pub timestamp: DateTime<Utc>,
781}
782
783#[derive(Debug, Clone)]
788pub struct SessionInfrastructure {
789 pub pid: Option<u32>,
791
792 pub process_start_time: Option<u64>,
795
796 pub socket_path: Option<PathBuf>,
798
799 pub transcript_path: Option<TranscriptPath>,
801
802 pub recent_tools: VecDeque<ToolUsageRecord>,
804
805 pub update_count: u64,
807
808 pub hook_event_count: u64,
810
811 pub last_error: Option<String>,
813}
814
815impl SessionInfrastructure {
816 const MAX_TOOL_HISTORY: usize = 50;
818
819 pub fn new() -> Self {
821 Self {
822 pid: None,
823 process_start_time: None,
824 socket_path: None,
825 transcript_path: None,
826 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
827 update_count: 0,
828 hook_event_count: 0,
829 last_error: None,
830 }
831 }
832
833 pub fn set_pid(&mut self, pid: u32) {
846 if pid == 0 {
848 return;
849 }
850
851 if self.pid == Some(pid) {
853 return;
854 }
855
856 if let Some(start_time) = read_process_start_time(pid) {
859 self.pid = Some(pid);
860 self.process_start_time = Some(start_time);
861 } else {
862 debug!(
863 pid = pid,
864 "PID validation failed - process may have exited or is inaccessible"
865 );
866 }
867 }
868
869 pub fn is_process_alive(&self) -> bool {
879 let Some(pid) = self.pid else {
880 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
882 return true;
883 };
884
885 let Some(expected_start_time) = self.process_start_time else {
886 let exists = procfs::process::Process::new(pid as i32).is_ok();
888 debug!(
889 pid,
890 exists, "is_process_alive: no start_time, checking procfs only"
891 );
892 return exists;
893 };
894
895 match read_process_start_time(pid) {
897 Some(current_start_time) => {
898 let alive = current_start_time == expected_start_time;
899 if !alive {
900 debug!(
901 pid,
902 expected_start_time,
903 current_start_time,
904 "is_process_alive: start time MISMATCH - PID reused?"
905 );
906 }
907 alive
908 }
909 None => {
910 debug!(
911 pid,
912 expected_start_time, "is_process_alive: process NOT FOUND in /proc"
913 );
914 false
915 }
916 }
917 }
918
919 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
921 let record = ToolUsageRecord {
922 tool_name: tool_name.to_string(),
923 tool_use_id,
924 timestamp: Utc::now(),
925 };
926
927 self.recent_tools.push_back(record);
928
929 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
931 self.recent_tools.pop_front();
932 }
933
934 self.hook_event_count += 1;
935 }
936
937 pub fn record_update(&mut self) {
939 self.update_count += 1;
940 }
941
942 pub fn record_error(&mut self, error: &str) {
944 self.last_error = Some(error.to_string());
945 }
946
947 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
949 self.recent_tools.back()
950 }
951
952 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
954 self.recent_tools.iter().rev()
955 }
956}
957
958fn read_process_start_time(pid: u32) -> Option<u64> {
965 let process = procfs::process::Process::new(pid as i32).ok()?;
966 let stat = process.stat().ok()?;
967 Some(stat.starttime)
968}
969
970impl Default for SessionInfrastructure {
971 fn default() -> Self {
972 Self::new()
973 }
974}
975
976#[derive(Debug, Clone, Default, Serialize, Deserialize)]
985pub struct SessionView {
986 pub id: SessionId,
988
989 pub id_short: String,
991
992 pub agent_type: String,
994
995 pub model: String,
997
998 pub status: SessionStatus,
1000
1001 pub status_label: String,
1003
1004 pub activity_detail: Option<String>,
1006
1007 pub should_blink: bool,
1009
1010 pub status_icon: String,
1012
1013 pub context_percentage: f64,
1015
1016 pub context_display: String,
1018
1019 pub context_warning: bool,
1021
1022 pub context_critical: bool,
1024
1025 pub cost_display: String,
1027
1028 pub cost_usd: f64,
1030
1031 pub duration_display: String,
1033
1034 pub duration_seconds: f64,
1036
1037 pub lines_display: String,
1039
1040 pub working_directory: Option<String>,
1042
1043 pub needs_attention: bool,
1045
1046 pub last_activity_display: String,
1048
1049 pub age_display: String,
1051
1052 pub started_at: String,
1054
1055 pub last_activity: String,
1057
1058 pub tmux_pane: Option<String>,
1060}
1061
1062impl SessionView {
1063 pub fn from_domain(session: &SessionDomain) -> Self {
1065 let now = Utc::now();
1066 let since_activity = now.signed_duration_since(session.last_activity);
1067 let age = now.signed_duration_since(session.started_at);
1068
1069 Self {
1070 id: session.id.clone(),
1071 id_short: session.id.short().to_string(),
1072 agent_type: session.agent_type.short_name().to_string(),
1073 model: if session.model.is_unknown() {
1074 session
1075 .model_display_override
1076 .clone()
1077 .unwrap_or_else(|| session.model.display_name().to_string())
1078 } else {
1079 session.model.display_name().to_string()
1080 },
1081 status: session.status,
1082 status_label: session.status.label().to_string(),
1083 activity_detail: session
1084 .current_activity
1085 .as_ref()
1086 .map(|a| a.display().into_owned()),
1087 should_blink: session.status.should_blink(),
1088 status_icon: session.status.icon().to_string(),
1089 context_percentage: session.context.usage_percentage(),
1090 context_display: session.context.format(),
1091 context_warning: session.context.is_warning(),
1092 context_critical: session.context.is_critical(),
1093 cost_display: session.cost.format(),
1094 cost_usd: session.cost.as_usd(),
1095 duration_display: session.duration.format(),
1096 duration_seconds: session.duration.total_seconds(),
1097 lines_display: session.lines_changed.format(),
1098 working_directory: session.working_directory.clone().map(|p| {
1099 if p.len() > 30 {
1101 format!("...{}", &p[p.len().saturating_sub(27)..])
1102 } else {
1103 p
1104 }
1105 }),
1106 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1107 last_activity_display: format_duration(since_activity),
1108 age_display: format_duration(age),
1109 started_at: session.started_at.to_rfc3339(),
1110 last_activity: session.last_activity.to_rfc3339(),
1111 tmux_pane: session.tmux_pane.clone(),
1112 }
1113 }
1114}
1115
1116impl From<&SessionDomain> for SessionView {
1117 fn from(session: &SessionDomain) -> Self {
1118 Self::from_domain(session)
1119 }
1120}
1121
1122fn format_duration(duration: chrono::Duration) -> String {
1124 let secs = duration.num_seconds();
1125 if secs < 0 {
1126 return "now".to_string();
1127 }
1128 if secs < 60 {
1129 format!("{secs}s ago")
1130 } else if secs < 3600 {
1131 let mins = secs / 60;
1132 format!("{mins}m ago")
1133 } else if secs < 86400 {
1134 let hours = secs / 3600;
1135 format!("{hours}h ago")
1136 } else {
1137 let days = secs / 86400;
1138 format!("{days}d ago")
1139 }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use super::*;
1145
1146 fn create_test_session(id: &str) -> SessionDomain {
1148 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1149 }
1150
1151 #[test]
1152 fn test_session_id_short() {
1153 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1154 assert_eq!(id.short(), "8e11bfb5");
1155 }
1156
1157 #[test]
1158 fn test_session_id_short_short_id() {
1159 let id = SessionId::new("abc");
1160 assert_eq!(id.short(), "abc");
1161 }
1162
1163 #[test]
1164 fn test_session_status_display() {
1165 let status = SessionStatus::Working;
1166 assert_eq!(format!("{status}"), "Working");
1167 }
1168
1169 #[test]
1170 fn test_session_domain_creation() {
1171 let session = SessionDomain::new(
1172 SessionId::new("test-123"),
1173 AgentType::GeneralPurpose,
1174 Model::Opus45,
1175 );
1176 assert_eq!(session.id.as_str(), "test-123");
1177 assert_eq!(session.model, Model::Opus45);
1178 assert!(session.cost.is_zero());
1179 }
1180
1181 #[test]
1182 fn test_session_view_from_domain() {
1183 let session = SessionDomain::new(
1184 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1185 AgentType::Explore,
1186 Model::Sonnet4,
1187 );
1188 let view = SessionView::from_domain(&session);
1189
1190 assert_eq!(view.id_short, "8e11bfb5");
1191 assert_eq!(view.agent_type, "explore");
1192 assert_eq!(view.model, "Sonnet 4");
1193 }
1194
1195 #[test]
1196 fn test_session_view_unknown_model_with_override() {
1197 let mut session = SessionDomain::new(
1198 SessionId::new("test-override"),
1199 AgentType::GeneralPurpose,
1200 Model::Unknown,
1201 );
1202 session.model_display_override = Some("GPT-4o".to_string());
1203
1204 let view = SessionView::from_domain(&session);
1205 assert_eq!(view.model, "GPT-4o");
1206 }
1207
1208 #[test]
1209 fn test_session_view_unknown_model_without_override() {
1210 let session = SessionDomain::new(
1211 SessionId::new("test-no-override"),
1212 AgentType::GeneralPurpose,
1213 Model::Unknown,
1214 );
1215
1216 let view = SessionView::from_domain(&session);
1217 assert_eq!(view.model, "Unknown");
1218 }
1219
1220 #[test]
1221 fn test_session_view_known_model_ignores_override() {
1222 let mut session = SessionDomain::new(
1223 SessionId::new("test-known"),
1224 AgentType::GeneralPurpose,
1225 Model::Opus46,
1226 );
1227 session.model_display_override = Some("something else".to_string());
1229
1230 let view = SessionView::from_domain(&session);
1231 assert_eq!(view.model, "Opus 4.6");
1232 }
1233
1234 #[test]
1235 fn test_lines_changed() {
1236 let lines = LinesChanged::new(150, 30);
1237 assert_eq!(lines.net(), 120);
1238 assert_eq!(lines.churn(), 180);
1239 assert_eq!(lines.format(), "+150 -30");
1240 assert_eq!(lines.format_net(), "+120");
1241 }
1242
1243 #[test]
1244 fn test_session_duration_formatting() {
1245 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1246 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1247 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1248 }
1249
1250 #[test]
1251 fn test_session_id_pending_from_pid() {
1252 let id = SessionId::pending_from_pid(12345);
1253 assert_eq!(id.as_str(), "pending-12345");
1254 assert!(id.is_pending());
1255 assert_eq!(id.pending_pid(), Some(12345));
1256 }
1257
1258 #[test]
1259 fn test_session_id_is_pending_true() {
1260 let id = SessionId::new("pending-99999");
1261 assert!(id.is_pending());
1262 }
1263
1264 #[test]
1265 fn test_session_id_is_pending_false() {
1266 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1267 assert!(!id.is_pending());
1268 }
1269
1270 #[test]
1271 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1272 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1273 assert_eq!(id.pending_pid(), None);
1274 }
1275
1276 #[test]
1277 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1278 let id = SessionId::new("pending-not-a-number");
1279 assert_eq!(id.pending_pid(), None);
1280 }
1281
1282 #[test]
1283 fn test_apply_hook_event_interactive_tool() {
1284 let mut session = create_test_session("test-interactive");
1285
1286 session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1288
1289 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1290 assert_eq!(
1291 session
1292 .current_activity
1293 .as_ref()
1294 .map(|a| a.display())
1295 .as_deref(),
1296 Some("AskUserQuestion")
1297 );
1298
1299 session.apply_hook_event(HookEventType::PostToolUse, None);
1301 assert_eq!(session.status, SessionStatus::Working);
1302 }
1303
1304 #[test]
1305 fn test_apply_hook_event_enter_plan_mode() {
1306 let mut session = create_test_session("test-plan");
1307
1308 session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1310
1311 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1312 assert_eq!(
1313 session
1314 .current_activity
1315 .as_ref()
1316 .map(|a| a.display())
1317 .as_deref(),
1318 Some("EnterPlanMode")
1319 );
1320 }
1321
1322 #[test]
1323 fn test_apply_hook_event_standard_tool() {
1324 let mut session = create_test_session("test-standard");
1325
1326 session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1328
1329 assert_eq!(session.status, SessionStatus::Working);
1330 assert_eq!(
1331 session
1332 .current_activity
1333 .as_ref()
1334 .map(|a| a.display())
1335 .as_deref(),
1336 Some("Bash")
1337 );
1338
1339 session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1341 assert_eq!(session.status, SessionStatus::Working);
1342 }
1343
1344 #[test]
1345 fn test_apply_hook_event_none_tool_name() {
1346 let mut session = create_test_session("test-none");
1347 let original_status = session.status;
1348
1349 session.apply_hook_event(HookEventType::PreToolUse, None);
1351
1352 assert_eq!(
1353 session.status, original_status,
1354 "PreToolUse with None tool_name should not change status"
1355 );
1356 }
1357
1358 #[test]
1359 fn test_apply_hook_event_empty_tool_name() {
1360 let mut session = create_test_session("test-empty");
1361
1362 session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1365
1366 assert_eq!(session.status, SessionStatus::Working);
1367 }
1368
1369 #[test]
1370 fn test_activity_detail_creation() {
1371 let detail = ActivityDetail::new("Bash");
1372 assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1373 assert!(detail.started_at <= Utc::now());
1374 assert!(detail.context.is_none());
1375 }
1376
1377 #[test]
1378 fn test_activity_detail_with_context() {
1379 let detail = ActivityDetail::with_context("Compacting");
1380 assert!(detail.tool_name.is_none());
1381 assert_eq!(detail.context.as_deref(), Some("Compacting"));
1382 }
1383
1384 #[test]
1385 fn test_activity_detail_display() {
1386 let detail = ActivityDetail::new("Read");
1387 assert_eq!(detail.display(), "Read");
1388
1389 let context_detail = ActivityDetail::with_context("Setup");
1390 assert_eq!(context_detail.display(), "Setup");
1391 }
1392
1393 #[test]
1394 fn test_new_session_status_variants() {
1395 let idle = SessionStatus::Idle;
1397 let working = SessionStatus::Working;
1398 let attention = SessionStatus::AttentionNeeded;
1399
1400 assert_eq!(idle.label(), "idle");
1401 assert_eq!(working.label(), "working");
1402 assert_eq!(attention.label(), "needs input");
1403 }
1404
1405 #[test]
1406 fn test_session_status_should_blink() {
1407 assert!(!SessionStatus::Idle.should_blink());
1408 assert!(!SessionStatus::Working.should_blink());
1409 assert!(SessionStatus::AttentionNeeded.should_blink());
1410 }
1411
1412 #[test]
1413 fn test_session_status_icons() {
1414 assert_eq!(SessionStatus::Idle.icon(), "-");
1415 assert_eq!(SessionStatus::Working.icon(), ">");
1416 assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1417 }
1418}