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)]
180#[serde(rename_all = "snake_case")]
181pub enum SessionStatus {
182 #[default]
185 Idle,
186
187 Working,
190
191 AttentionNeeded,
194}
195
196impl SessionStatus {
197 #[must_use]
199 pub fn label(&self) -> &'static str {
200 match self {
201 Self::Idle => "idle",
202 Self::Working => "working",
203 Self::AttentionNeeded => "needs input",
204 }
205 }
206
207 #[must_use]
209 pub fn icon(&self) -> &'static str {
210 match self {
211 Self::Idle => "-",
212 Self::Working => ">",
213 Self::AttentionNeeded => "!",
214 }
215 }
216
217 #[must_use]
219 pub fn should_blink(&self) -> bool {
220 matches!(self, Self::AttentionNeeded)
221 }
222
223 #[must_use]
225 pub fn is_active(&self) -> bool {
226 matches!(self, Self::Working)
227 }
228
229 #[must_use]
231 pub fn needs_attention(&self) -> bool {
232 matches!(self, Self::AttentionNeeded)
233 }
234}
235
236impl fmt::Display for SessionStatus {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 match self {
239 Self::Idle => write!(f, "Idle"),
240 Self::Working => write!(f, "Working"),
241 Self::AttentionNeeded => write!(f, "Needs Input"),
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct ActivityDetail {
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub tool_name: Option<String>,
259 pub started_at: DateTime<Utc>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub context: Option<String>,
264}
265
266impl ActivityDetail {
267 pub fn new(tool_name: &str) -> Self {
269 Self {
270 tool_name: Some(tool_name.to_string()),
271 started_at: Utc::now(),
272 context: None,
273 }
274 }
275
276 pub fn with_context(context: &str) -> Self {
278 Self {
279 tool_name: None,
280 started_at: Utc::now(),
281 context: Some(context.to_string()),
282 }
283 }
284
285 pub fn thinking() -> Self {
287 Self::with_context("Thinking")
288 }
289
290 pub fn duration(&self) -> chrono::Duration {
292 Utc::now().signed_duration_since(self.started_at)
293 }
294
295 #[must_use]
299 pub fn display(&self) -> Cow<'_, str> {
300 if let Some(ref tool) = self.tool_name {
301 Cow::Borrowed(tool)
302 } else if let Some(ref ctx) = self.context {
303 Cow::Borrowed(ctx)
304 } else {
305 Cow::Borrowed("Unknown")
306 }
307 }
308}
309
310impl Default for ActivityDetail {
311 fn default() -> Self {
312 Self::thinking()
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
324pub struct SessionDuration {
325 total_ms: u64,
327 api_ms: u64,
329}
330
331impl SessionDuration {
332 pub fn new(total_ms: u64, api_ms: u64) -> Self {
334 Self { total_ms, api_ms }
335 }
336
337 pub fn from_total_ms(total_ms: u64) -> Self {
339 Self { total_ms, api_ms: 0 }
340 }
341
342 pub fn total_ms(&self) -> u64 {
344 self.total_ms
345 }
346
347 pub fn api_ms(&self) -> u64 {
349 self.api_ms
350 }
351
352 pub fn total_seconds(&self) -> f64 {
354 self.total_ms as f64 / 1000.0
355 }
356
357 pub fn overhead_ms(&self) -> u64 {
359 self.total_ms.saturating_sub(self.api_ms)
360 }
361
362 pub fn format(&self) -> String {
366 let secs = self.total_ms / 1000;
367 if secs < 60 {
368 format!("{secs}s")
369 } else if secs < 3600 {
370 let mins = secs / 60;
371 let remaining_secs = secs % 60;
372 if remaining_secs == 0 {
373 format!("{mins}m")
374 } else {
375 format!("{mins}m {remaining_secs}s")
376 }
377 } else {
378 let hours = secs / 3600;
379 let remaining_mins = (secs % 3600) / 60;
380 if remaining_mins == 0 {
381 format!("{hours}h")
382 } else {
383 format!("{hours}h {remaining_mins}m")
384 }
385 }
386 }
387
388 pub fn format_compact(&self) -> String {
390 let secs = self.total_ms / 1000;
391 if secs < 60 {
392 format!("{secs}s")
393 } else if secs < 3600 {
394 let mins = secs / 60;
395 format!("{mins}m")
396 } else {
397 let hours = secs / 3600;
398 format!("{hours}h")
399 }
400 }
401}
402
403impl fmt::Display for SessionDuration {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 write!(f, "{}", self.format())
406 }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
413pub struct LinesChanged {
414 pub added: u64,
416 pub removed: u64,
418}
419
420impl LinesChanged {
421 pub fn new(added: u64, removed: u64) -> Self {
423 Self { added, removed }
424 }
425
426 pub fn net(&self) -> i64 {
428 self.added as i64 - self.removed as i64
429 }
430
431 pub fn churn(&self) -> u64 {
433 self.added.saturating_add(self.removed)
434 }
435
436 pub fn is_empty(&self) -> bool {
438 self.added == 0 && self.removed == 0
439 }
440
441 pub fn format(&self) -> String {
443 format!("+{} -{}", self.added, self.removed)
444 }
445
446 pub fn format_net(&self) -> String {
448 let net = self.net();
449 if net >= 0 {
450 format!("+{net}")
451 } else {
452 format!("{net}")
453 }
454 }
455}
456
457impl fmt::Display for LinesChanged {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 write!(f, "{}", self.format())
460 }
461}
462
463#[derive(Debug, Clone, Default)]
473pub struct StatusLineData {
474 pub session_id: String,
476 pub model_id: 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 pub status: SessionStatus,
531
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub current_activity: Option<ActivityDetail>,
535
536 pub context: ContextUsage,
538
539 pub cost: Money,
541
542 pub duration: SessionDuration,
544
545 pub lines_changed: LinesChanged,
547
548 pub started_at: DateTime<Utc>,
550
551 pub last_activity: DateTime<Utc>,
553
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub working_directory: Option<String>,
557
558 #[serde(skip_serializing_if = "Option::is_none")]
560 pub claude_code_version: Option<String>,
561
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub tmux_pane: Option<String>,
565}
566
567impl SessionDomain {
568 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
570 let now = Utc::now();
571 Self {
572 id,
573 agent_type,
574 model,
575 status: SessionStatus::Idle,
576 current_activity: None,
577 context: ContextUsage::new(model.context_window_size()),
578 cost: Money::zero(),
579 duration: SessionDuration::default(),
580 lines_changed: LinesChanged::default(),
581 started_at: now,
582 last_activity: now,
583 working_directory: None,
584 claude_code_version: None,
585 tmux_pane: None,
586 }
587 }
588
589 pub fn from_status_line(data: &StatusLineData) -> Self {
591 let model = Model::from_id(&data.model_id);
592
593 let mut session = Self::new(
594 SessionId::new(&data.session_id),
595 AgentType::GeneralPurpose, model,
597 );
598
599 session.cost = Money::from_usd(data.cost_usd);
600 session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
601 session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
602 session.context = ContextUsage {
603 total_input_tokens: TokenCount::new(data.total_input_tokens),
604 total_output_tokens: TokenCount::new(data.total_output_tokens),
605 context_window_size: data.context_window_size,
606 current_input_tokens: TokenCount::new(data.current_input_tokens),
607 current_output_tokens: TokenCount::new(data.current_output_tokens),
608 cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
609 cache_read_tokens: TokenCount::new(data.cache_read_tokens),
610 };
611 session.working_directory = data.cwd.clone();
612 session.claude_code_version = data.version.clone();
613 session.last_activity = Utc::now();
614
615 session
616 }
617
618 pub fn update_from_status_line(&mut self, data: &StatusLineData) {
623 self.cost = Money::from_usd(data.cost_usd);
624 self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
625 self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
626 self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
627 self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
628 self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
629 self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
630 self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
631 self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
632 self.last_activity = Utc::now();
633
634 if self.status != SessionStatus::AttentionNeeded {
637 self.status = SessionStatus::Working;
638 }
639 }
640
641 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
643 self.last_activity = Utc::now();
644
645 match event_type {
646 HookEventType::PreToolUse => {
647 if let Some(name) = tool_name {
648 if is_interactive_tool(name) {
649 self.status = SessionStatus::AttentionNeeded;
650 self.current_activity = Some(ActivityDetail::new(name));
651 } else {
652 self.status = SessionStatus::Working;
653 self.current_activity = Some(ActivityDetail::new(name));
654 }
655 }
656 }
657 HookEventType::PostToolUse | HookEventType::PostToolUseFailure => {
658 self.status = SessionStatus::Working;
659 self.current_activity = Some(ActivityDetail::thinking());
660 }
661 HookEventType::UserPromptSubmit => {
662 self.status = SessionStatus::Working;
663 self.current_activity = None;
664 }
665 HookEventType::Stop => {
666 self.status = SessionStatus::Idle;
667 self.current_activity = None;
668 }
669 HookEventType::SessionStart => {
670 self.status = SessionStatus::Idle;
671 self.current_activity = None;
672 }
673 HookEventType::SessionEnd => {
674 self.status = SessionStatus::Idle;
676 self.current_activity = None;
677 }
678 HookEventType::PreCompact => {
679 self.status = SessionStatus::Working;
680 self.current_activity = Some(ActivityDetail::with_context("Compacting"));
681 }
682 HookEventType::Setup => {
683 self.status = SessionStatus::Working;
684 self.current_activity = Some(ActivityDetail::with_context("Setup"));
685 }
686 HookEventType::Notification => {
687 }
690 HookEventType::SubagentStart | HookEventType::SubagentStop => {
691 self.status = SessionStatus::Working;
693 }
694 }
695 }
696
697 pub fn apply_notification(&mut self, notification_type: Option<&str>) {
699 self.last_activity = Utc::now();
700
701 match notification_type {
702 Some("permission_prompt") => {
703 self.status = SessionStatus::AttentionNeeded;
704 self.current_activity = Some(ActivityDetail::with_context("Permission"));
705 }
706 Some("idle_prompt") => {
707 self.status = SessionStatus::Idle;
708 self.current_activity = None;
709 }
710 Some("elicitation_dialog") => {
711 self.status = SessionStatus::AttentionNeeded;
712 self.current_activity = Some(ActivityDetail::with_context("MCP Input"));
713 }
714 _ => {
715 }
717 }
718 }
719
720 pub fn age(&self) -> chrono::Duration {
722 Utc::now().signed_duration_since(self.started_at)
723 }
724
725 pub fn time_since_activity(&self) -> chrono::Duration {
727 Utc::now().signed_duration_since(self.last_activity)
728 }
729
730 pub fn is_stale(&self) -> bool {
734 self.time_since_activity() > chrono::Duration::hours(8)
735 }
736
737 pub fn needs_context_attention(&self) -> bool {
739 self.context.is_warning() || self.context.is_critical()
740 }
741}
742
743impl Default for SessionDomain {
744 fn default() -> Self {
745 Self::new(
746 SessionId::new("unknown"),
747 AgentType::default(),
748 Model::default(),
749 )
750 }
751}
752
753#[derive(Debug, Clone)]
759pub struct ToolUsageRecord {
760 pub tool_name: String,
762 pub tool_use_id: Option<ToolUseId>,
764 pub timestamp: DateTime<Utc>,
766}
767
768#[derive(Debug, Clone)]
773pub struct SessionInfrastructure {
774 pub pid: Option<u32>,
776
777 pub process_start_time: Option<u64>,
780
781 pub socket_path: Option<PathBuf>,
783
784 pub transcript_path: Option<TranscriptPath>,
786
787 pub recent_tools: VecDeque<ToolUsageRecord>,
789
790 pub update_count: u64,
792
793 pub hook_event_count: u64,
795
796 pub last_error: Option<String>,
798}
799
800impl SessionInfrastructure {
801 const MAX_TOOL_HISTORY: usize = 50;
803
804 pub fn new() -> Self {
806 Self {
807 pid: None,
808 process_start_time: None,
809 socket_path: None,
810 transcript_path: None,
811 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
812 update_count: 0,
813 hook_event_count: 0,
814 last_error: None,
815 }
816 }
817
818 pub fn set_pid(&mut self, pid: u32) {
831 if pid == 0 {
833 return;
834 }
835
836 if self.pid == Some(pid) {
838 return;
839 }
840
841 if let Some(start_time) = read_process_start_time(pid) {
844 self.pid = Some(pid);
845 self.process_start_time = Some(start_time);
846 } else {
847 debug!(
848 pid = pid,
849 "PID validation failed - process may have exited or is inaccessible"
850 );
851 }
852 }
853
854 pub fn is_process_alive(&self) -> bool {
864 let Some(pid) = self.pid else {
865 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
867 return true;
868 };
869
870 let Some(expected_start_time) = self.process_start_time else {
871 let exists = procfs::process::Process::new(pid as i32).is_ok();
873 debug!(pid, exists, "is_process_alive: no start_time, checking procfs only");
874 return exists;
875 };
876
877 match read_process_start_time(pid) {
879 Some(current_start_time) => {
880 let alive = current_start_time == expected_start_time;
881 if !alive {
882 debug!(
883 pid,
884 expected_start_time,
885 current_start_time,
886 "is_process_alive: start time MISMATCH - PID reused?"
887 );
888 }
889 alive
890 }
891 None => {
892 debug!(pid, expected_start_time, "is_process_alive: process NOT FOUND in /proc");
893 false
894 }
895 }
896 }
897
898 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
900 let record = ToolUsageRecord {
901 tool_name: tool_name.to_string(),
902 tool_use_id,
903 timestamp: Utc::now(),
904 };
905
906 self.recent_tools.push_back(record);
907
908 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
910 self.recent_tools.pop_front();
911 }
912
913 self.hook_event_count += 1;
914 }
915
916 pub fn record_update(&mut self) {
918 self.update_count += 1;
919 }
920
921 pub fn record_error(&mut self, error: &str) {
923 self.last_error = Some(error.to_string());
924 }
925
926 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
928 self.recent_tools.back()
929 }
930
931 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
933 self.recent_tools.iter().rev()
934 }
935}
936
937fn read_process_start_time(pid: u32) -> Option<u64> {
944 let process = procfs::process::Process::new(pid as i32).ok()?;
945 let stat = process.stat().ok()?;
946 Some(stat.starttime)
947}
948
949impl Default for SessionInfrastructure {
950 fn default() -> Self {
951 Self::new()
952 }
953}
954
955#[derive(Debug, Clone, Default, Serialize, Deserialize)]
964pub struct SessionView {
965 pub id: SessionId,
967
968 pub id_short: String,
970
971 pub agent_type: String,
973
974 pub model: String,
976
977 pub status: SessionStatus,
979
980 pub status_label: String,
982
983 pub activity_detail: Option<String>,
985
986 pub should_blink: bool,
988
989 pub status_icon: String,
991
992 pub context_percentage: f64,
994
995 pub context_display: String,
997
998 pub context_warning: bool,
1000
1001 pub context_critical: bool,
1003
1004 pub cost_display: String,
1006
1007 pub cost_usd: f64,
1009
1010 pub duration_display: String,
1012
1013 pub duration_seconds: f64,
1015
1016 pub lines_display: String,
1018
1019 pub working_directory: Option<String>,
1021
1022 pub is_stale: bool,
1024
1025 pub needs_attention: bool,
1027
1028 pub last_activity_display: String,
1030
1031 pub age_display: String,
1033
1034 pub started_at: String,
1036
1037 pub last_activity: String,
1039
1040 pub tmux_pane: Option<String>,
1042}
1043
1044impl SessionView {
1045 pub fn from_domain(session: &SessionDomain) -> Self {
1047 let now = Utc::now();
1048 let since_activity = now.signed_duration_since(session.last_activity);
1049 let age = now.signed_duration_since(session.started_at);
1050
1051 Self {
1052 id: session.id.clone(),
1053 id_short: session.id.short().to_string(),
1054 agent_type: session.agent_type.short_name().to_string(),
1055 model: session.model.display_name().to_string(),
1056 status: session.status,
1057 status_label: session.status.label().to_string(),
1058 activity_detail: session.current_activity.as_ref().map(|a| a.display().into_owned()),
1059 should_blink: session.status.should_blink(),
1060 status_icon: session.status.icon().to_string(),
1061 context_percentage: session.context.usage_percentage(),
1062 context_display: session.context.format(),
1063 context_warning: session.context.is_warning(),
1064 context_critical: session.context.is_critical(),
1065 cost_display: session.cost.format(),
1066 cost_usd: session.cost.as_usd(),
1067 duration_display: session.duration.format(),
1068 duration_seconds: session.duration.total_seconds(),
1069 lines_display: session.lines_changed.format(),
1070 working_directory: session.working_directory.clone().map(|p| {
1071 if p.len() > 30 {
1073 format!("...{}", &p[p.len().saturating_sub(27)..])
1074 } else {
1075 p
1076 }
1077 }),
1078 is_stale: session.is_stale(),
1079 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1080 last_activity_display: format_duration(since_activity),
1081 age_display: format_duration(age),
1082 started_at: session.started_at.to_rfc3339(),
1083 last_activity: session.last_activity.to_rfc3339(),
1084 tmux_pane: session.tmux_pane.clone(),
1085 }
1086 }
1087}
1088
1089impl From<&SessionDomain> for SessionView {
1090 fn from(session: &SessionDomain) -> Self {
1091 Self::from_domain(session)
1092 }
1093}
1094
1095fn format_duration(duration: chrono::Duration) -> String {
1097 let secs = duration.num_seconds();
1098 if secs < 0 {
1099 return "now".to_string();
1100 }
1101 if secs < 60 {
1102 format!("{secs}s ago")
1103 } else if secs < 3600 {
1104 let mins = secs / 60;
1105 format!("{mins}m ago")
1106 } else if secs < 86400 {
1107 let hours = secs / 3600;
1108 format!("{hours}h ago")
1109 } else {
1110 let days = secs / 86400;
1111 format!("{days}d ago")
1112 }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117 use super::*;
1118
1119 fn create_test_session(id: &str) -> SessionDomain {
1121 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1122 }
1123
1124 #[test]
1125 fn test_session_id_short() {
1126 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1127 assert_eq!(id.short(), "8e11bfb5");
1128 }
1129
1130 #[test]
1131 fn test_session_id_short_short_id() {
1132 let id = SessionId::new("abc");
1133 assert_eq!(id.short(), "abc");
1134 }
1135
1136 #[test]
1137 fn test_session_status_display() {
1138 let status = SessionStatus::Working;
1139 assert_eq!(format!("{status}"), "Working");
1140 }
1141
1142 #[test]
1143 fn test_session_domain_creation() {
1144 let session = SessionDomain::new(
1145 SessionId::new("test-123"),
1146 AgentType::GeneralPurpose,
1147 Model::Opus45,
1148 );
1149 assert_eq!(session.id.as_str(), "test-123");
1150 assert_eq!(session.model, Model::Opus45);
1151 assert!(session.cost.is_zero());
1152 }
1153
1154 #[test]
1155 fn test_session_view_from_domain() {
1156 let session = SessionDomain::new(
1157 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1158 AgentType::Explore,
1159 Model::Sonnet4,
1160 );
1161 let view = SessionView::from_domain(&session);
1162
1163 assert_eq!(view.id_short, "8e11bfb5");
1164 assert_eq!(view.agent_type, "explore");
1165 assert_eq!(view.model, "Sonnet 4");
1166 }
1167
1168 #[test]
1169 fn test_lines_changed() {
1170 let lines = LinesChanged::new(150, 30);
1171 assert_eq!(lines.net(), 120);
1172 assert_eq!(lines.churn(), 180);
1173 assert_eq!(lines.format(), "+150 -30");
1174 assert_eq!(lines.format_net(), "+120");
1175 }
1176
1177 #[test]
1178 fn test_session_duration_formatting() {
1179 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1180 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1181 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1182 }
1183
1184 #[test]
1185 fn test_session_id_pending_from_pid() {
1186 let id = SessionId::pending_from_pid(12345);
1187 assert_eq!(id.as_str(), "pending-12345");
1188 assert!(id.is_pending());
1189 assert_eq!(id.pending_pid(), Some(12345));
1190 }
1191
1192 #[test]
1193 fn test_session_id_is_pending_true() {
1194 let id = SessionId::new("pending-99999");
1195 assert!(id.is_pending());
1196 }
1197
1198 #[test]
1199 fn test_session_id_is_pending_false() {
1200 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1201 assert!(!id.is_pending());
1202 }
1203
1204 #[test]
1205 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1206 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1207 assert_eq!(id.pending_pid(), None);
1208 }
1209
1210 #[test]
1211 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1212 let id = SessionId::new("pending-not-a-number");
1213 assert_eq!(id.pending_pid(), None);
1214 }
1215
1216 #[test]
1217 fn test_apply_hook_event_interactive_tool() {
1218 let mut session = create_test_session("test-interactive");
1219
1220 session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1222
1223 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1224 assert_eq!(
1225 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1226 Some("AskUserQuestion")
1227 );
1228
1229 session.apply_hook_event(HookEventType::PostToolUse, None);
1231 assert_eq!(session.status, SessionStatus::Working);
1232 }
1233
1234 #[test]
1235 fn test_apply_hook_event_enter_plan_mode() {
1236 let mut session = create_test_session("test-plan");
1237
1238 session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1240
1241 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1242 assert_eq!(
1243 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1244 Some("EnterPlanMode")
1245 );
1246 }
1247
1248 #[test]
1249 fn test_apply_hook_event_standard_tool() {
1250 let mut session = create_test_session("test-standard");
1251
1252 session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1254
1255 assert_eq!(session.status, SessionStatus::Working);
1256 assert_eq!(
1257 session.current_activity.as_ref().map(|a| a.display()).as_deref(),
1258 Some("Bash")
1259 );
1260
1261 session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1263 assert_eq!(session.status, SessionStatus::Working);
1264 }
1265
1266 #[test]
1267 fn test_apply_hook_event_none_tool_name() {
1268 let mut session = create_test_session("test-none");
1269 let original_status = session.status;
1270
1271 session.apply_hook_event(HookEventType::PreToolUse, None);
1273
1274 assert_eq!(
1275 session.status, original_status,
1276 "PreToolUse with None tool_name should not change status"
1277 );
1278 }
1279
1280 #[test]
1281 fn test_apply_hook_event_empty_tool_name() {
1282 let mut session = create_test_session("test-empty");
1283
1284 session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1287
1288 assert_eq!(session.status, SessionStatus::Working);
1289 }
1290
1291 #[test]
1292 fn test_activity_detail_creation() {
1293 let detail = ActivityDetail::new("Bash");
1294 assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1295 assert!(detail.started_at <= Utc::now());
1296 assert!(detail.context.is_none());
1297 }
1298
1299 #[test]
1300 fn test_activity_detail_with_context() {
1301 let detail = ActivityDetail::with_context("Compacting");
1302 assert!(detail.tool_name.is_none());
1303 assert_eq!(detail.context.as_deref(), Some("Compacting"));
1304 }
1305
1306 #[test]
1307 fn test_activity_detail_display() {
1308 let detail = ActivityDetail::new("Read");
1309 assert_eq!(detail.display(), "Read");
1310
1311 let context_detail = ActivityDetail::with_context("Setup");
1312 assert_eq!(context_detail.display(), "Setup");
1313 }
1314
1315 #[test]
1316 fn test_new_session_status_variants() {
1317 let idle = SessionStatus::Idle;
1319 let working = SessionStatus::Working;
1320 let attention = SessionStatus::AttentionNeeded;
1321
1322 assert_eq!(idle.label(), "idle");
1323 assert_eq!(working.label(), "working");
1324 assert_eq!(attention.label(), "needs input");
1325 }
1326
1327 #[test]
1328 fn test_session_status_should_blink() {
1329 assert!(!SessionStatus::Idle.should_blink());
1330 assert!(!SessionStatus::Working.should_blink());
1331 assert!(SessionStatus::AttentionNeeded.should_blink());
1332 }
1333
1334 #[test]
1335 fn test_session_status_icons() {
1336 assert_eq!(SessionStatus::Idle.icon(), "-");
1337 assert_eq!(SessionStatus::Working.icon(), ">");
1338 assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1339 }
1340}