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 { total_ms, api_ms: 0 }
338 }
339
340 pub fn total_ms(&self) -> u64 {
342 self.total_ms
343 }
344
345 pub fn api_ms(&self) -> u64 {
347 self.api_ms
348 }
349
350 pub fn total_seconds(&self) -> f64 {
352 self.total_ms as f64 / 1000.0
353 }
354
355 pub fn overhead_ms(&self) -> u64 {
357 self.total_ms.saturating_sub(self.api_ms)
358 }
359
360 pub fn format(&self) -> String {
364 let secs = self.total_ms / 1000;
365 if secs < 60 {
366 format!("{secs}s")
367 } else if secs < 3600 {
368 let mins = secs / 60;
369 let remaining_secs = secs % 60;
370 if remaining_secs == 0 {
371 format!("{mins}m")
372 } else {
373 format!("{mins}m {remaining_secs}s")
374 }
375 } else {
376 let hours = secs / 3600;
377 let remaining_mins = (secs % 3600) / 60;
378 if remaining_mins == 0 {
379 format!("{hours}h")
380 } else {
381 format!("{hours}h {remaining_mins}m")
382 }
383 }
384 }
385
386 pub fn format_compact(&self) -> String {
388 let secs = self.total_ms / 1000;
389 if secs < 60 {
390 format!("{secs}s")
391 } else if secs < 3600 {
392 let mins = secs / 60;
393 format!("{mins}m")
394 } else {
395 let hours = secs / 3600;
396 format!("{hours}h")
397 }
398 }
399}
400
401impl fmt::Display for SessionDuration {
402 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403 write!(f, "{}", self.format())
404 }
405}
406
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
411pub struct LinesChanged {
412 pub added: u64,
414 pub removed: u64,
416}
417
418impl LinesChanged {
419 pub fn new(added: u64, removed: u64) -> Self {
421 Self { added, removed }
422 }
423
424 pub fn net(&self) -> i64 {
426 self.added as i64 - self.removed as i64
427 }
428
429 pub fn churn(&self) -> u64 {
431 self.added.saturating_add(self.removed)
432 }
433
434 pub fn is_empty(&self) -> bool {
436 self.added == 0 && self.removed == 0
437 }
438
439 pub fn format(&self) -> String {
441 format!("+{} -{}", self.added, self.removed)
442 }
443
444 pub fn format_net(&self) -> String {
446 let net = self.net();
447 if net >= 0 {
448 format!("+{net}")
449 } else {
450 format!("{net}")
451 }
452 }
453}
454
455impl fmt::Display for LinesChanged {
456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457 write!(f, "{}", self.format())
458 }
459}
460
461#[derive(Debug, Clone, Default)]
471pub struct StatusLineData {
472 pub session_id: String,
474 pub model_id: String,
476 pub model_display_name: Option<String>,
478 pub cost_usd: f64,
480 pub total_duration_ms: u64,
482 pub api_duration_ms: u64,
484 pub lines_added: u64,
486 pub lines_removed: u64,
488 pub total_input_tokens: u64,
490 pub total_output_tokens: u64,
492 pub context_window_size: u32,
494 pub current_input_tokens: u64,
496 pub current_output_tokens: u64,
498 pub cache_creation_tokens: u64,
500 pub cache_read_tokens: u64,
502 pub cwd: Option<String>,
504 pub version: Option<String>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct SessionDomain {
520 pub id: SessionId,
522
523 pub agent_type: AgentType,
525
526 pub model: Model,
528
529 #[serde(skip_serializing_if = "Option::is_none")]
533 pub model_display_override: Option<String>,
534
535 pub status: SessionStatus,
537
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub current_activity: Option<ActivityDetail>,
541
542 pub context: ContextUsage,
544
545 pub cost: Money,
547
548 pub duration: SessionDuration,
550
551 pub lines_changed: LinesChanged,
553
554 pub started_at: DateTime<Utc>,
556
557 pub last_activity: DateTime<Utc>,
559
560 #[serde(skip_serializing_if = "Option::is_none")]
562 pub working_directory: Option<String>,
563
564 #[serde(skip_serializing_if = "Option::is_none")]
566 pub claude_code_version: Option<String>,
567
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub tmux_pane: Option<String>,
571}
572
573impl SessionDomain {
574 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
576 let now = Utc::now();
577 Self {
578 id,
579 agent_type,
580 model,
581 model_display_override: None,
582 status: SessionStatus::Idle,
583 current_activity: None,
584 context: ContextUsage::new(model.context_window_size()),
585 cost: Money::zero(),
586 duration: SessionDuration::default(),
587 lines_changed: LinesChanged::default(),
588 started_at: now,
589 last_activity: now,
590 working_directory: None,
591 claude_code_version: None,
592 tmux_pane: None,
593 }
594 }
595
596 pub fn from_status_line(data: &StatusLineData) -> Self {
598 use crate::model::derive_display_name;
599
600 let model = Model::from_id(&data.model_id);
601
602 let mut session = Self::new(
603 SessionId::new(&data.session_id),
604 AgentType::GeneralPurpose, model,
606 );
607
608 if model.is_unknown() && !data.model_id.is_empty() {
611 session.model_display_override = Some(
612 data.model_display_name
613 .clone()
614 .unwrap_or_else(|| derive_display_name(&data.model_id)),
615 );
616 }
617
618 session.cost = Money::from_usd(data.cost_usd);
619 session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
620 session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
621 session.context = ContextUsage {
622 total_input_tokens: TokenCount::new(data.total_input_tokens),
623 total_output_tokens: TokenCount::new(data.total_output_tokens),
624 context_window_size: data.context_window_size,
625 current_input_tokens: TokenCount::new(data.current_input_tokens),
626 current_output_tokens: TokenCount::new(data.current_output_tokens),
627 cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
628 cache_read_tokens: TokenCount::new(data.cache_read_tokens),
629 };
630 session.working_directory = data.cwd.clone();
631 session.claude_code_version = data.version.clone();
632 session.last_activity = Utc::now();
633
634 session
635 }
636
637 pub fn update_from_status_line(&mut self, data: &StatusLineData) {
642 self.cost = Money::from_usd(data.cost_usd);
643 self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
644 self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
645 self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
646 self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
647 self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
648 self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
649 self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
650 self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
651 self.last_activity = Utc::now();
652
653 if self.status != SessionStatus::AttentionNeeded {
656 self.status = SessionStatus::Working;
657 }
658 }
659
660 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
662 self.last_activity = Utc::now();
663
664 match event_type {
665 HookEventType::PreToolUse => {
666 if let Some(name) = tool_name {
667 if is_interactive_tool(name) {
668 self.status = SessionStatus::AttentionNeeded;
669 self.current_activity = Some(ActivityDetail::new(name));
670 } else {
671 self.status = SessionStatus::Working;
672 self.current_activity = Some(ActivityDetail::new(name));
673 }
674 }
675 }
676 HookEventType::PostToolUse | HookEventType::PostToolUseFailure => {
677 self.status = SessionStatus::Working;
678 self.current_activity = Some(ActivityDetail::thinking());
679 }
680 HookEventType::UserPromptSubmit => {
681 self.status = SessionStatus::Working;
682 self.current_activity = None;
683 }
684 HookEventType::Stop => {
685 self.status = SessionStatus::Idle;
686 self.current_activity = None;
687 }
688 HookEventType::SessionStart => {
689 self.status = SessionStatus::Idle;
690 self.current_activity = None;
691 }
692 HookEventType::SessionEnd => {
693 self.status = SessionStatus::Idle;
695 self.current_activity = None;
696 }
697 HookEventType::PreCompact => {
698 self.status = SessionStatus::Working;
699 self.current_activity = Some(ActivityDetail::with_context("Compacting"));
700 }
701 HookEventType::Setup => {
702 self.status = SessionStatus::Working;
703 self.current_activity = Some(ActivityDetail::with_context("Setup"));
704 }
705 HookEventType::Notification => {
706 }
709 HookEventType::SubagentStart | HookEventType::SubagentStop => {
710 self.status = SessionStatus::Working;
712 }
713 }
714 }
715
716 pub fn apply_notification(&mut self, notification_type: Option<&str>) {
718 self.last_activity = Utc::now();
719
720 match notification_type {
721 Some("permission_prompt") => {
722 self.status = SessionStatus::AttentionNeeded;
723 self.current_activity = Some(ActivityDetail::with_context("Permission"));
724 }
725 Some("idle_prompt") => {
726 self.status = SessionStatus::Idle;
727 self.current_activity = None;
728 }
729 Some("elicitation_dialog") => {
730 self.status = SessionStatus::AttentionNeeded;
731 self.current_activity = Some(ActivityDetail::with_context("MCP Input"));
732 }
733 _ => {
734 }
736 }
737 }
738
739 pub fn age(&self) -> chrono::Duration {
741 Utc::now().signed_duration_since(self.started_at)
742 }
743
744 pub fn time_since_activity(&self) -> chrono::Duration {
746 Utc::now().signed_duration_since(self.last_activity)
747 }
748
749 pub fn needs_context_attention(&self) -> bool {
751 self.context.is_warning() || self.context.is_critical()
752 }
753}
754
755impl Default for SessionDomain {
756 fn default() -> Self {
757 Self::new(
758 SessionId::new("unknown"),
759 AgentType::default(),
760 Model::default(),
761 )
762 }
763}
764
765#[derive(Debug, Clone)]
771pub struct ToolUsageRecord {
772 pub tool_name: String,
774 pub tool_use_id: Option<ToolUseId>,
776 pub timestamp: DateTime<Utc>,
778}
779
780#[derive(Debug, Clone)]
785pub struct SessionInfrastructure {
786 pub pid: Option<u32>,
788
789 pub process_start_time: Option<u64>,
792
793 pub socket_path: Option<PathBuf>,
795
796 pub transcript_path: Option<TranscriptPath>,
798
799 pub recent_tools: VecDeque<ToolUsageRecord>,
801
802 pub update_count: u64,
804
805 pub hook_event_count: u64,
807
808 pub last_error: Option<String>,
810}
811
812impl SessionInfrastructure {
813 const MAX_TOOL_HISTORY: usize = 50;
815
816 pub fn new() -> Self {
818 Self {
819 pid: None,
820 process_start_time: None,
821 socket_path: None,
822 transcript_path: None,
823 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
824 update_count: 0,
825 hook_event_count: 0,
826 last_error: None,
827 }
828 }
829
830 pub fn set_pid(&mut self, pid: u32) {
843 if pid == 0 {
845 return;
846 }
847
848 if self.pid == Some(pid) {
850 return;
851 }
852
853 if let Some(start_time) = read_process_start_time(pid) {
856 self.pid = Some(pid);
857 self.process_start_time = Some(start_time);
858 } else {
859 debug!(
860 pid = pid,
861 "PID validation failed - process may have exited or is inaccessible"
862 );
863 }
864 }
865
866 pub fn is_process_alive(&self) -> bool {
876 let Some(pid) = self.pid else {
877 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
879 return true;
880 };
881
882 let Some(expected_start_time) = self.process_start_time else {
883 let exists = procfs::process::Process::new(pid as i32).is_ok();
885 debug!(pid, exists, "is_process_alive: no start_time, checking procfs only");
886 return exists;
887 };
888
889 match read_process_start_time(pid) {
891 Some(current_start_time) => {
892 let alive = current_start_time == expected_start_time;
893 if !alive {
894 debug!(
895 pid,
896 expected_start_time,
897 current_start_time,
898 "is_process_alive: start time MISMATCH - PID reused?"
899 );
900 }
901 alive
902 }
903 None => {
904 debug!(pid, expected_start_time, "is_process_alive: process NOT FOUND in /proc");
905 false
906 }
907 }
908 }
909
910 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
912 let record = ToolUsageRecord {
913 tool_name: tool_name.to_string(),
914 tool_use_id,
915 timestamp: Utc::now(),
916 };
917
918 self.recent_tools.push_back(record);
919
920 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
922 self.recent_tools.pop_front();
923 }
924
925 self.hook_event_count += 1;
926 }
927
928 pub fn record_update(&mut self) {
930 self.update_count += 1;
931 }
932
933 pub fn record_error(&mut self, error: &str) {
935 self.last_error = Some(error.to_string());
936 }
937
938 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
940 self.recent_tools.back()
941 }
942
943 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
945 self.recent_tools.iter().rev()
946 }
947}
948
949fn read_process_start_time(pid: u32) -> Option<u64> {
956 let process = procfs::process::Process::new(pid as i32).ok()?;
957 let stat = process.stat().ok()?;
958 Some(stat.starttime)
959}
960
961impl Default for SessionInfrastructure {
962 fn default() -> Self {
963 Self::new()
964 }
965}
966
967#[derive(Debug, Clone, Default, Serialize, Deserialize)]
976pub struct SessionView {
977 pub id: SessionId,
979
980 pub id_short: String,
982
983 pub agent_type: String,
985
986 pub model: String,
988
989 pub status: SessionStatus,
991
992 pub status_label: String,
994
995 pub activity_detail: Option<String>,
997
998 pub should_blink: bool,
1000
1001 pub status_icon: String,
1003
1004 pub context_percentage: f64,
1006
1007 pub context_display: String,
1009
1010 pub context_warning: bool,
1012
1013 pub context_critical: bool,
1015
1016 pub cost_display: String,
1018
1019 pub cost_usd: f64,
1021
1022 pub duration_display: String,
1024
1025 pub duration_seconds: f64,
1027
1028 pub lines_display: String,
1030
1031 pub working_directory: Option<String>,
1033
1034 pub needs_attention: bool,
1036
1037 pub last_activity_display: String,
1039
1040 pub age_display: String,
1042
1043 pub started_at: String,
1045
1046 pub last_activity: String,
1048
1049 pub tmux_pane: Option<String>,
1051}
1052
1053impl SessionView {
1054 pub fn from_domain(session: &SessionDomain) -> Self {
1056 let now = Utc::now();
1057 let since_activity = now.signed_duration_since(session.last_activity);
1058 let age = now.signed_duration_since(session.started_at);
1059
1060 Self {
1061 id: session.id.clone(),
1062 id_short: session.id.short().to_string(),
1063 agent_type: session.agent_type.short_name().to_string(),
1064 model: if session.model.is_unknown() {
1065 session
1066 .model_display_override
1067 .clone()
1068 .unwrap_or_else(|| session.model.display_name().to_string())
1069 } else {
1070 session.model.display_name().to_string()
1071 },
1072 status: session.status,
1073 status_label: session.status.label().to_string(),
1074 activity_detail: session.current_activity.as_ref().map(|a| a.display().into_owned()),
1075 should_blink: session.status.should_blink(),
1076 status_icon: session.status.icon().to_string(),
1077 context_percentage: session.context.usage_percentage(),
1078 context_display: session.context.format(),
1079 context_warning: session.context.is_warning(),
1080 context_critical: session.context.is_critical(),
1081 cost_display: session.cost.format(),
1082 cost_usd: session.cost.as_usd(),
1083 duration_display: session.duration.format(),
1084 duration_seconds: session.duration.total_seconds(),
1085 lines_display: session.lines_changed.format(),
1086 working_directory: session.working_directory.clone().map(|p| {
1087 if p.len() > 30 {
1089 format!("...{}", &p[p.len().saturating_sub(27)..])
1090 } else {
1091 p
1092 }
1093 }),
1094 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1095 last_activity_display: format_duration(since_activity),
1096 age_display: format_duration(age),
1097 started_at: session.started_at.to_rfc3339(),
1098 last_activity: session.last_activity.to_rfc3339(),
1099 tmux_pane: session.tmux_pane.clone(),
1100 }
1101 }
1102}
1103
1104impl From<&SessionDomain> for SessionView {
1105 fn from(session: &SessionDomain) -> Self {
1106 Self::from_domain(session)
1107 }
1108}
1109
1110fn format_duration(duration: chrono::Duration) -> String {
1112 let secs = duration.num_seconds();
1113 if secs < 0 {
1114 return "now".to_string();
1115 }
1116 if secs < 60 {
1117 format!("{secs}s ago")
1118 } else if secs < 3600 {
1119 let mins = secs / 60;
1120 format!("{mins}m ago")
1121 } else if secs < 86400 {
1122 let hours = secs / 3600;
1123 format!("{hours}h ago")
1124 } else {
1125 let days = secs / 86400;
1126 format!("{days}d ago")
1127 }
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133
1134 fn create_test_session(id: &str) -> SessionDomain {
1136 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1137 }
1138
1139 #[test]
1140 fn test_session_id_short() {
1141 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1142 assert_eq!(id.short(), "8e11bfb5");
1143 }
1144
1145 #[test]
1146 fn test_session_id_short_short_id() {
1147 let id = SessionId::new("abc");
1148 assert_eq!(id.short(), "abc");
1149 }
1150
1151 #[test]
1152 fn test_session_status_display() {
1153 let status = SessionStatus::Working;
1154 assert_eq!(format!("{status}"), "Working");
1155 }
1156
1157 #[test]
1158 fn test_session_domain_creation() {
1159 let session = SessionDomain::new(
1160 SessionId::new("test-123"),
1161 AgentType::GeneralPurpose,
1162 Model::Opus45,
1163 );
1164 assert_eq!(session.id.as_str(), "test-123");
1165 assert_eq!(session.model, Model::Opus45);
1166 assert!(session.cost.is_zero());
1167 }
1168
1169 #[test]
1170 fn test_session_view_from_domain() {
1171 let session = SessionDomain::new(
1172 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1173 AgentType::Explore,
1174 Model::Sonnet4,
1175 );
1176 let view = SessionView::from_domain(&session);
1177
1178 assert_eq!(view.id_short, "8e11bfb5");
1179 assert_eq!(view.agent_type, "explore");
1180 assert_eq!(view.model, "Sonnet 4");
1181 }
1182
1183 #[test]
1184 fn test_session_view_unknown_model_with_override() {
1185 let mut session = SessionDomain::new(
1186 SessionId::new("test-override"),
1187 AgentType::GeneralPurpose,
1188 Model::Unknown,
1189 );
1190 session.model_display_override = Some("GPT-4o".to_string());
1191
1192 let view = SessionView::from_domain(&session);
1193 assert_eq!(view.model, "GPT-4o");
1194 }
1195
1196 #[test]
1197 fn test_session_view_unknown_model_without_override() {
1198 let session = SessionDomain::new(
1199 SessionId::new("test-no-override"),
1200 AgentType::GeneralPurpose,
1201 Model::Unknown,
1202 );
1203
1204 let view = SessionView::from_domain(&session);
1205 assert_eq!(view.model, "Unknown");
1206 }
1207
1208 #[test]
1209 fn test_session_view_known_model_ignores_override() {
1210 let mut session = SessionDomain::new(
1211 SessionId::new("test-known"),
1212 AgentType::GeneralPurpose,
1213 Model::Opus46,
1214 );
1215 session.model_display_override = Some("something else".to_string());
1217
1218 let view = SessionView::from_domain(&session);
1219 assert_eq!(view.model, "Opus 4.6");
1220 }
1221
1222 #[test]
1223 fn test_lines_changed() {
1224 let lines = LinesChanged::new(150, 30);
1225 assert_eq!(lines.net(), 120);
1226 assert_eq!(lines.churn(), 180);
1227 assert_eq!(lines.format(), "+150 -30");
1228 assert_eq!(lines.format_net(), "+120");
1229 }
1230
1231 #[test]
1232 fn test_session_duration_formatting() {
1233 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1234 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1235 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1236 }
1237
1238 #[test]
1239 fn test_session_id_pending_from_pid() {
1240 let id = SessionId::pending_from_pid(12345);
1241 assert_eq!(id.as_str(), "pending-12345");
1242 assert!(id.is_pending());
1243 assert_eq!(id.pending_pid(), Some(12345));
1244 }
1245
1246 #[test]
1247 fn test_session_id_is_pending_true() {
1248 let id = SessionId::new("pending-99999");
1249 assert!(id.is_pending());
1250 }
1251
1252 #[test]
1253 fn test_session_id_is_pending_false() {
1254 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1255 assert!(!id.is_pending());
1256 }
1257
1258 #[test]
1259 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1260 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1261 assert_eq!(id.pending_pid(), None);
1262 }
1263
1264 #[test]
1265 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1266 let id = SessionId::new("pending-not-a-number");
1267 assert_eq!(id.pending_pid(), None);
1268 }
1269
1270 #[test]
1271 fn test_apply_hook_event_interactive_tool() {
1272 let mut session = create_test_session("test-interactive");
1273
1274 session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1276
1277 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1278 assert_eq!(
1279 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1280 Some("AskUserQuestion")
1281 );
1282
1283 session.apply_hook_event(HookEventType::PostToolUse, None);
1285 assert_eq!(session.status, SessionStatus::Working);
1286 }
1287
1288 #[test]
1289 fn test_apply_hook_event_enter_plan_mode() {
1290 let mut session = create_test_session("test-plan");
1291
1292 session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1294
1295 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1296 assert_eq!(
1297 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1298 Some("EnterPlanMode")
1299 );
1300 }
1301
1302 #[test]
1303 fn test_apply_hook_event_standard_tool() {
1304 let mut session = create_test_session("test-standard");
1305
1306 session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1308
1309 assert_eq!(session.status, SessionStatus::Working);
1310 assert_eq!(
1311 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1312 Some("Bash")
1313 );
1314
1315 session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1317 assert_eq!(session.status, SessionStatus::Working);
1318 }
1319
1320 #[test]
1321 fn test_apply_hook_event_none_tool_name() {
1322 let mut session = create_test_session("test-none");
1323 let original_status = session.status;
1324
1325 session.apply_hook_event(HookEventType::PreToolUse, None);
1327
1328 assert_eq!(
1329 session.status, original_status,
1330 "PreToolUse with None tool_name should not change status"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_apply_hook_event_empty_tool_name() {
1336 let mut session = create_test_session("test-empty");
1337
1338 session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1341
1342 assert_eq!(session.status, SessionStatus::Working);
1343 }
1344
1345 #[test]
1346 fn test_activity_detail_creation() {
1347 let detail = ActivityDetail::new("Bash");
1348 assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1349 assert!(detail.started_at <= Utc::now());
1350 assert!(detail.context.is_none());
1351 }
1352
1353 #[test]
1354 fn test_activity_detail_with_context() {
1355 let detail = ActivityDetail::with_context("Compacting");
1356 assert!(detail.tool_name.is_none());
1357 assert_eq!(detail.context.as_deref(), Some("Compacting"));
1358 }
1359
1360 #[test]
1361 fn test_activity_detail_display() {
1362 let detail = ActivityDetail::new("Read");
1363 assert_eq!(detail.display(), "Read");
1364
1365 let context_detail = ActivityDetail::with_context("Setup");
1366 assert_eq!(context_detail.display(), "Setup");
1367 }
1368
1369 #[test]
1370 fn test_new_session_status_variants() {
1371 let idle = SessionStatus::Idle;
1373 let working = SessionStatus::Working;
1374 let attention = SessionStatus::AttentionNeeded;
1375
1376 assert_eq!(idle.label(), "idle");
1377 assert_eq!(working.label(), "working");
1378 assert_eq!(attention.label(), "needs input");
1379 }
1380
1381 #[test]
1382 fn test_session_status_should_blink() {
1383 assert!(!SessionStatus::Idle.should_blink());
1384 assert!(!SessionStatus::Working.should_blink());
1385 assert!(SessionStatus::AttentionNeeded.should_blink());
1386 }
1387
1388 #[test]
1389 fn test_session_status_icons() {
1390 assert_eq!(SessionStatus::Idle.icon(), "-");
1391 assert_eq!(SessionStatus::Working.icon(), ">");
1392 assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1393 }
1394}