1use crate::{AgentType, ContextUsage, HookEventType, Model, Money, TokenCount};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use tracing::debug;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SessionId(String);
22
23pub const PENDING_SESSION_PREFIX: &str = "pending-";
25
26impl SessionId {
27 pub fn new(id: impl Into<String>) -> Self {
32 Self(id.into())
33 }
34
35 pub fn pending_from_pid(pid: u32) -> Self {
42 Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
43 }
44
45 pub fn is_pending(&self) -> bool {
47 self.0.starts_with(PENDING_SESSION_PREFIX)
48 }
49
50 pub fn pending_pid(&self) -> Option<u32> {
54 if !self.is_pending() {
55 return None;
56 }
57 self.0
58 .strip_prefix(PENDING_SESSION_PREFIX)
59 .and_then(|s| s.parse().ok())
60 }
61
62 pub fn as_str(&self) -> &str {
64 &self.0
65 }
66
67 pub fn short(&self) -> &str {
71 self.0.get(..8).unwrap_or(&self.0)
72 }
73}
74
75impl fmt::Display for SessionId {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "{}", self.0)
78 }
79}
80
81impl From<String> for SessionId {
82 fn from(s: String) -> Self {
83 Self(s)
84 }
85}
86
87impl From<&str> for SessionId {
88 fn from(s: &str) -> Self {
89 Self(s.to_string())
90 }
91}
92
93impl AsRef<str> for SessionId {
94 fn as_ref(&self) -> &str {
95 &self.0
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
104#[serde(transparent)]
105pub struct ToolUseId(String);
106
107impl ToolUseId {
108 pub fn new(id: impl Into<String>) -> Self {
109 Self(id.into())
110 }
111
112 pub fn as_str(&self) -> &str {
113 &self.0
114 }
115}
116
117impl fmt::Display for ToolUseId {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 write!(f, "{}", self.0)
120 }
121}
122
123impl From<String> for ToolUseId {
124 fn from(s: String) -> Self {
125 Self(s)
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
133#[serde(transparent)]
134pub struct TranscriptPath(PathBuf);
135
136impl TranscriptPath {
137 pub fn new(path: impl Into<PathBuf>) -> Self {
138 Self(path.into())
139 }
140
141 pub fn as_path(&self) -> &Path {
142 &self.0
143 }
144
145 pub fn filename(&self) -> Option<&str> {
147 self.0.file_name().and_then(|n| n.to_str())
148 }
149}
150
151impl fmt::Display for TranscriptPath {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 write!(f, "{}", self.0.display())
154 }
155}
156
157impl AsRef<Path> for TranscriptPath {
158 fn as_ref(&self) -> &Path {
159 &self.0
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(tag = "status", rename_all = "snake_case")]
172pub enum SessionStatus {
173 Active,
175
176 Thinking,
178
179 RunningTool {
181 tool_name: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
185 started_at: Option<DateTime<Utc>>,
186 },
187
188 WaitingForPermission {
190 tool_name: String,
192 },
193
194 Idle,
196
197 Stale,
199}
200
201impl SessionStatus {
202 pub fn is_active(&self) -> bool {
204 matches!(
205 self,
206 Self::Active | Self::Thinking | Self::RunningTool { .. }
207 )
208 }
209
210 pub fn needs_attention(&self) -> bool {
212 matches!(self, Self::WaitingForPermission { .. })
213 }
214
215 pub fn is_removable(&self) -> bool {
217 matches!(self, Self::Stale)
218 }
219
220 pub fn label(&self) -> &str {
222 match self {
223 Self::Active => "active",
224 Self::Thinking => "thinking",
225 Self::RunningTool { .. } => "running",
226 Self::WaitingForPermission { .. } => "waiting",
227 Self::Idle => "idle",
228 Self::Stale => "stale",
229 }
230 }
231
232 pub fn tool_name(&self) -> Option<&str> {
234 match self {
235 Self::RunningTool { tool_name, .. } => Some(tool_name.as_str()),
236 Self::WaitingForPermission { tool_name } => Some(tool_name.as_str()),
237 _ => None,
238 }
239 }
240}
241
242impl Default for SessionStatus {
243 fn default() -> Self {
244 Self::Active
245 }
246}
247
248impl fmt::Display for SessionStatus {
249 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250 match self {
251 Self::Active => write!(f, "Active"),
252 Self::Thinking => write!(f, "Thinking..."),
253 Self::RunningTool { tool_name, .. } => write!(f, "Running: {tool_name}"),
254 Self::WaitingForPermission { tool_name } => {
255 write!(f, "Permission: {tool_name}")
256 }
257 Self::Idle => write!(f, "Idle"),
258 Self::Stale => write!(f, "Stale"),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum DisplayState {
288 #[default]
290 Working,
291
292 Compacting,
294
295 NeedsInput,
297
298 Idle,
300
301 Stale,
303}
304
305impl DisplayState {
306 const ACTIVITY_THRESHOLD_SECS: i64 = 5;
308
309 const STALE_THRESHOLD_SECS: i64 = 8 * 3600;
311
312 const COMPACTION_HIGH_THRESHOLD: f64 = 70.0;
314
315 const COMPACTION_DROP_THRESHOLD: f64 = 20.0;
317
318 pub fn label(&self) -> &'static str {
320 match self {
321 Self::Working => "working",
322 Self::Compacting => "compacting",
323 Self::NeedsInput => "needs input",
324 Self::Idle => "idle",
325 Self::Stale => "stale",
326 }
327 }
328
329 pub fn icon(&self) -> &'static str {
338 match self {
339 Self::Working => ">",
340 Self::Compacting => "~",
341 Self::NeedsInput => "!",
342 Self::Idle => "-",
343 Self::Stale => "z",
344 }
345 }
346
347 pub fn description(&self) -> &'static str {
349 match self {
350 Self::Working => "Session is actively processing",
351 Self::Compacting => "Working after context reduction",
352 Self::NeedsInput => "Waiting for user input or permission",
353 Self::Idle => "Session idle, awaiting user prompt",
354 Self::Stale => "No activity for extended period",
355 }
356 }
357
358 pub fn should_blink(&self) -> bool {
367 matches!(self, Self::NeedsInput)
368 }
369
370 pub fn from_session(
388 time_since_activity_secs: i64,
389 status: &SessionStatus,
390 context_percentage: f64,
391 previous_context_percentage: Option<f64>,
392 ) -> Self {
393 if time_since_activity_secs > Self::STALE_THRESHOLD_SECS {
395 return Self::Stale;
396 }
397
398 match status {
401 SessionStatus::Stale => return Self::Stale,
402 SessionStatus::Idle => return Self::Idle,
403 _ => {}
404 }
405
406 if status.needs_attention() {
408 return Self::NeedsInput;
409 }
410
411 match status {
415 SessionStatus::Thinking | SessionStatus::RunningTool { .. } => {
416 if let Some(prev_pct) = previous_context_percentage {
418 let dropped = prev_pct - context_percentage;
419 if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
420 && dropped >= Self::COMPACTION_DROP_THRESHOLD
421 {
422 return Self::Compacting;
423 }
424 }
425 return Self::Working;
426 }
427 _ => {}
428 }
429
430 let has_recent_activity = time_since_activity_secs < Self::ACTIVITY_THRESHOLD_SECS;
432
433 if !has_recent_activity {
436 return Self::Idle;
437 }
438
439 if status.is_active() {
441 if let Some(prev_pct) = previous_context_percentage {
443 let dropped = prev_pct - context_percentage;
444 if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
445 && dropped >= Self::COMPACTION_DROP_THRESHOLD
446 {
447 return Self::Compacting;
448 }
449 }
450 return Self::Working;
451 }
452
453 Self::Idle
455 }
456}
457
458impl fmt::Display for DisplayState {
459 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
460 write!(f, "{}", self.label())
461 }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
472pub struct SessionDuration {
473 total_ms: u64,
475 api_ms: u64,
477}
478
479impl SessionDuration {
480 pub fn new(total_ms: u64, api_ms: u64) -> Self {
482 Self { total_ms, api_ms }
483 }
484
485 pub fn from_total_ms(total_ms: u64) -> Self {
487 Self { total_ms, api_ms: 0 }
488 }
489
490 pub fn total_ms(&self) -> u64 {
492 self.total_ms
493 }
494
495 pub fn api_ms(&self) -> u64 {
497 self.api_ms
498 }
499
500 pub fn total_seconds(&self) -> f64 {
502 self.total_ms as f64 / 1000.0
503 }
504
505 pub fn overhead_ms(&self) -> u64 {
507 self.total_ms.saturating_sub(self.api_ms)
508 }
509
510 pub fn format(&self) -> String {
514 let secs = self.total_ms / 1000;
515 if secs < 60 {
516 format!("{secs}s")
517 } else if secs < 3600 {
518 let mins = secs / 60;
519 let remaining_secs = secs % 60;
520 if remaining_secs == 0 {
521 format!("{mins}m")
522 } else {
523 format!("{mins}m {remaining_secs}s")
524 }
525 } else {
526 let hours = secs / 3600;
527 let remaining_mins = (secs % 3600) / 60;
528 if remaining_mins == 0 {
529 format!("{hours}h")
530 } else {
531 format!("{hours}h {remaining_mins}m")
532 }
533 }
534 }
535
536 pub fn format_compact(&self) -> String {
538 let secs = self.total_ms / 1000;
539 if secs < 60 {
540 format!("{secs}s")
541 } else if secs < 3600 {
542 let mins = secs / 60;
543 format!("{mins}m")
544 } else {
545 let hours = secs / 3600;
546 format!("{hours}h")
547 }
548 }
549}
550
551impl fmt::Display for SessionDuration {
552 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
553 write!(f, "{}", self.format())
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
561pub struct LinesChanged {
562 pub added: u64,
564 pub removed: u64,
566}
567
568impl LinesChanged {
569 pub fn new(added: u64, removed: u64) -> Self {
571 Self { added, removed }
572 }
573
574 pub fn net(&self) -> i64 {
576 self.added as i64 - self.removed as i64
577 }
578
579 pub fn churn(&self) -> u64 {
581 self.added.saturating_add(self.removed)
582 }
583
584 pub fn is_empty(&self) -> bool {
586 self.added == 0 && self.removed == 0
587 }
588
589 pub fn format(&self) -> String {
591 format!("+{} -{}", self.added, self.removed)
592 }
593
594 pub fn format_net(&self) -> String {
596 let net = self.net();
597 if net >= 0 {
598 format!("+{net}")
599 } else {
600 format!("{net}")
601 }
602 }
603}
604
605impl fmt::Display for LinesChanged {
606 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
607 write!(f, "{}", self.format())
608 }
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct SessionDomain {
623 pub id: SessionId,
625
626 pub agent_type: AgentType,
628
629 pub model: Model,
631
632 pub status: SessionStatus,
634
635 pub context: ContextUsage,
637
638 pub cost: Money,
640
641 pub duration: SessionDuration,
643
644 pub lines_changed: LinesChanged,
646
647 pub started_at: DateTime<Utc>,
649
650 pub last_activity: DateTime<Utc>,
652
653 #[serde(skip_serializing_if = "Option::is_none")]
655 pub working_directory: Option<String>,
656
657 #[serde(skip_serializing_if = "Option::is_none")]
659 pub claude_code_version: Option<String>,
660
661 #[serde(skip_serializing_if = "Option::is_none")]
663 pub tmux_pane: Option<String>,
664}
665
666impl SessionDomain {
667 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
669 let now = Utc::now();
670 Self {
671 id,
672 agent_type,
673 model,
674 status: SessionStatus::Active,
675 context: ContextUsage::new(model.context_window_size()),
676 cost: Money::zero(),
677 duration: SessionDuration::default(),
678 lines_changed: LinesChanged::default(),
679 started_at: now,
680 last_activity: now,
681 working_directory: None,
682 claude_code_version: None,
683 tmux_pane: None,
684 }
685 }
686
687 #[allow(clippy::too_many_arguments)]
689 pub fn from_status_line(
690 session_id: &str,
691 model_id: &str,
692 cost_usd: f64,
693 total_duration_ms: u64,
694 api_duration_ms: u64,
695 lines_added: u64,
696 lines_removed: u64,
697 total_input_tokens: u64,
698 total_output_tokens: u64,
699 context_window_size: u32,
700 current_input_tokens: u64,
701 current_output_tokens: u64,
702 cache_creation_tokens: u64,
703 cache_read_tokens: u64,
704 cwd: Option<&str>,
705 version: Option<&str>,
706 ) -> Self {
707 let model = Model::from_id(model_id);
708
709 let mut session = Self::new(
710 SessionId::new(session_id),
711 AgentType::GeneralPurpose, model,
713 );
714
715 session.cost = Money::from_usd(cost_usd);
716 session.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
717 session.lines_changed = LinesChanged::new(lines_added, lines_removed);
718 session.context = ContextUsage {
719 total_input_tokens: TokenCount::new(total_input_tokens),
720 total_output_tokens: TokenCount::new(total_output_tokens),
721 context_window_size,
722 current_input_tokens: TokenCount::new(current_input_tokens),
723 current_output_tokens: TokenCount::new(current_output_tokens),
724 cache_creation_tokens: TokenCount::new(cache_creation_tokens),
725 cache_read_tokens: TokenCount::new(cache_read_tokens),
726 };
727 session.working_directory = cwd.map(|s| s.to_string());
728 session.claude_code_version = version.map(|s| s.to_string());
729 session.last_activity = Utc::now();
730
731 session
732 }
733
734 #[allow(clippy::too_many_arguments)]
739 pub fn update_from_status_line(
740 &mut self,
741 cost_usd: f64,
742 total_duration_ms: u64,
743 api_duration_ms: u64,
744 lines_added: u64,
745 lines_removed: u64,
746 total_input_tokens: u64,
747 total_output_tokens: u64,
748 current_input_tokens: u64,
749 current_output_tokens: u64,
750 cache_creation_tokens: u64,
751 cache_read_tokens: u64,
752 ) {
753 self.cost = Money::from_usd(cost_usd);
754 self.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
755 self.lines_changed = LinesChanged::new(lines_added, lines_removed);
756 self.context.total_input_tokens = TokenCount::new(total_input_tokens);
757 self.context.total_output_tokens = TokenCount::new(total_output_tokens);
758 self.context.current_input_tokens = TokenCount::new(current_input_tokens);
759 self.context.current_output_tokens = TokenCount::new(current_output_tokens);
760 self.context.cache_creation_tokens = TokenCount::new(cache_creation_tokens);
761 self.context.cache_read_tokens = TokenCount::new(cache_read_tokens);
762 self.last_activity = Utc::now();
763
764 if !matches!(self.status, SessionStatus::WaitingForPermission { .. }) {
766 self.status = SessionStatus::Active;
767 }
768 }
769
770 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
772 self.last_activity = Utc::now();
773
774 match event_type {
775 HookEventType::PreToolUse => {
776 if let Some(name) = tool_name {
777 self.status = SessionStatus::RunningTool {
778 tool_name: name.to_string(),
779 started_at: Some(Utc::now()),
780 };
781 }
782 }
783 HookEventType::PostToolUse => {
784 self.status = SessionStatus::Thinking;
785 }
786 _ => {}
787 }
788 }
789
790 pub fn set_waiting_for_permission(&mut self, tool_name: &str) {
792 self.status = SessionStatus::WaitingForPermission {
793 tool_name: tool_name.to_string(),
794 };
795 self.last_activity = Utc::now();
796 }
797
798 pub fn age(&self) -> chrono::Duration {
800 Utc::now().signed_duration_since(self.started_at)
801 }
802
803 pub fn time_since_activity(&self) -> chrono::Duration {
805 Utc::now().signed_duration_since(self.last_activity)
806 }
807
808 pub fn is_stale(&self) -> bool {
812 self.time_since_activity() > chrono::Duration::hours(8)
813 }
814
815 pub fn needs_context_attention(&self) -> bool {
817 self.context.is_warning() || self.context.is_critical()
818 }
819}
820
821impl Default for SessionDomain {
822 fn default() -> Self {
823 Self::new(
824 SessionId::new("unknown"),
825 AgentType::default(),
826 Model::default(),
827 )
828 }
829}
830
831#[derive(Debug, Clone)]
837pub struct ToolUsageRecord {
838 pub tool_name: String,
840 pub tool_use_id: Option<ToolUseId>,
842 pub timestamp: DateTime<Utc>,
844}
845
846#[derive(Debug, Clone)]
851pub struct SessionInfrastructure {
852 pub pid: Option<u32>,
854
855 pub process_start_time: Option<u64>,
858
859 pub socket_path: Option<PathBuf>,
861
862 pub transcript_path: Option<TranscriptPath>,
864
865 pub recent_tools: VecDeque<ToolUsageRecord>,
867
868 pub update_count: u64,
870
871 pub hook_event_count: u64,
873
874 pub last_error: Option<String>,
876}
877
878impl SessionInfrastructure {
879 const MAX_TOOL_HISTORY: usize = 50;
881
882 pub fn new() -> Self {
884 Self {
885 pid: None,
886 process_start_time: None,
887 socket_path: None,
888 transcript_path: None,
889 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
890 update_count: 0,
891 hook_event_count: 0,
892 last_error: None,
893 }
894 }
895
896 pub fn set_pid(&mut self, pid: u32) {
909 if pid == 0 {
911 return;
912 }
913
914 if self.pid == Some(pid) {
916 return;
917 }
918
919 if let Some(start_time) = read_process_start_time(pid) {
922 self.pid = Some(pid);
923 self.process_start_time = Some(start_time);
924 } else {
925 debug!(
926 pid = pid,
927 "PID validation failed - process may have exited or is inaccessible"
928 );
929 }
930 }
931
932 pub fn is_process_alive(&self) -> bool {
942 let Some(pid) = self.pid else {
943 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
945 return true;
946 };
947
948 let Some(expected_start_time) = self.process_start_time else {
949 let exists = procfs::process::Process::new(pid as i32).is_ok();
951 debug!(pid, exists, "is_process_alive: no start_time, checking procfs only");
952 return exists;
953 };
954
955 match read_process_start_time(pid) {
957 Some(current_start_time) => {
958 let alive = current_start_time == expected_start_time;
959 if !alive {
960 debug!(
961 pid,
962 expected_start_time,
963 current_start_time,
964 "is_process_alive: start time MISMATCH - PID reused?"
965 );
966 }
967 alive
968 }
969 None => {
970 debug!(pid, expected_start_time, "is_process_alive: process NOT FOUND in /proc");
971 false
972 }
973 }
974 }
975
976 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
978 let record = ToolUsageRecord {
979 tool_name: tool_name.to_string(),
980 tool_use_id,
981 timestamp: Utc::now(),
982 };
983
984 self.recent_tools.push_back(record);
985
986 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
988 self.recent_tools.pop_front();
989 }
990
991 self.hook_event_count += 1;
992 }
993
994 pub fn record_update(&mut self) {
996 self.update_count += 1;
997 }
998
999 pub fn record_error(&mut self, error: &str) {
1001 self.last_error = Some(error.to_string());
1002 }
1003
1004 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1006 self.recent_tools.back()
1007 }
1008
1009 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1011 self.recent_tools.iter().rev()
1012 }
1013}
1014
1015fn read_process_start_time(pid: u32) -> Option<u64> {
1022 let process = procfs::process::Process::new(pid as i32).ok()?;
1023 let stat = process.stat().ok()?;
1024 Some(stat.starttime)
1025}
1026
1027impl Default for SessionInfrastructure {
1028 fn default() -> Self {
1029 Self::new()
1030 }
1031}
1032
1033#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1042pub struct SessionView {
1043 pub id: SessionId,
1045
1046 pub id_short: String,
1048
1049 pub agent_type: String,
1051
1052 pub model: String,
1054
1055 pub status: String,
1057
1058 pub status_detail: Option<String>,
1060
1061 pub context_percentage: f64,
1063
1064 pub context_display: String,
1066
1067 pub context_warning: bool,
1069
1070 pub context_critical: bool,
1072
1073 pub cost_display: String,
1075
1076 pub cost_usd: f64,
1078
1079 pub duration_display: String,
1081
1082 pub duration_seconds: f64,
1084
1085 pub lines_display: String,
1087
1088 pub working_directory: Option<String>,
1090
1091 pub is_stale: bool,
1093
1094 pub needs_attention: bool,
1096
1097 pub last_activity_display: String,
1099
1100 pub age_display: String,
1102
1103 pub started_at: String,
1105
1106 pub last_activity: String,
1108
1109 pub tmux_pane: Option<String>,
1111
1112 pub display_state: DisplayState,
1114}
1115
1116impl SessionView {
1117 pub fn from_domain(session: &SessionDomain) -> Self {
1119 let now = Utc::now();
1120 let since_activity = now.signed_duration_since(session.last_activity);
1121 let age = now.signed_duration_since(session.started_at);
1122
1123 Self {
1124 id: session.id.clone(),
1125 id_short: session.id.short().to_string(),
1126 agent_type: session.agent_type.short_name().to_string(),
1127 model: session.model.display_name().to_string(),
1128 status: session.status.label().to_string(),
1129 status_detail: session.status.tool_name().map(|s| s.to_string()),
1130 context_percentage: session.context.usage_percentage(),
1131 context_display: session.context.format(),
1132 context_warning: session.context.is_warning(),
1133 context_critical: session.context.is_critical(),
1134 cost_display: session.cost.format(),
1135 cost_usd: session.cost.as_usd(),
1136 duration_display: session.duration.format(),
1137 duration_seconds: session.duration.total_seconds(),
1138 lines_display: session.lines_changed.format(),
1139 working_directory: session.working_directory.clone().map(|p| {
1140 if p.len() > 30 {
1142 format!("...{}", &p[p.len().saturating_sub(27)..])
1143 } else {
1144 p
1145 }
1146 }),
1147 is_stale: session.is_stale(),
1148 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1149 last_activity_display: format_duration(since_activity),
1150 age_display: format_duration(age),
1151 started_at: session.started_at.to_rfc3339(),
1152 last_activity: session.last_activity.to_rfc3339(),
1153 tmux_pane: session.tmux_pane.clone(),
1154 display_state: DisplayState::from_session(
1155 since_activity.num_seconds(),
1156 &session.status,
1157 session.context.usage_percentage(),
1158 None, ),
1160 }
1161 }
1162}
1163
1164impl From<&SessionDomain> for SessionView {
1165 fn from(session: &SessionDomain) -> Self {
1166 Self::from_domain(session)
1167 }
1168}
1169
1170fn format_duration(duration: chrono::Duration) -> String {
1172 let secs = duration.num_seconds();
1173 if secs < 0 {
1174 return "now".to_string();
1175 }
1176 if secs < 60 {
1177 format!("{secs}s ago")
1178 } else if secs < 3600 {
1179 let mins = secs / 60;
1180 format!("{mins}m ago")
1181 } else if secs < 86400 {
1182 let hours = secs / 3600;
1183 format!("{hours}h ago")
1184 } else {
1185 let days = secs / 86400;
1186 format!("{days}d ago")
1187 }
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192 use super::*;
1193
1194 #[test]
1195 fn test_session_id_short() {
1196 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1197 assert_eq!(id.short(), "8e11bfb5");
1198 }
1199
1200 #[test]
1201 fn test_session_id_short_short_id() {
1202 let id = SessionId::new("abc");
1203 assert_eq!(id.short(), "abc");
1204 }
1205
1206 #[test]
1207 fn test_session_status_display() {
1208 let status = SessionStatus::RunningTool {
1209 tool_name: "Bash".to_string(),
1210 started_at: None,
1211 };
1212 assert_eq!(format!("{status}"), "Running: Bash");
1213 }
1214
1215 #[test]
1216 fn test_session_domain_creation() {
1217 let session = SessionDomain::new(
1218 SessionId::new("test-123"),
1219 AgentType::GeneralPurpose,
1220 Model::Opus45,
1221 );
1222 assert_eq!(session.id.as_str(), "test-123");
1223 assert_eq!(session.model, Model::Opus45);
1224 assert!(session.cost.is_zero());
1225 }
1226
1227 #[test]
1228 fn test_session_view_from_domain() {
1229 let session = SessionDomain::new(
1230 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1231 AgentType::Explore,
1232 Model::Sonnet4,
1233 );
1234 let view = SessionView::from_domain(&session);
1235
1236 assert_eq!(view.id_short, "8e11bfb5");
1237 assert_eq!(view.agent_type, "explore");
1238 assert_eq!(view.model, "Sonnet 4");
1239 }
1240
1241 #[test]
1242 fn test_lines_changed() {
1243 let lines = LinesChanged::new(150, 30);
1244 assert_eq!(lines.net(), 120);
1245 assert_eq!(lines.churn(), 180);
1246 assert_eq!(lines.format(), "+150 -30");
1247 assert_eq!(lines.format_net(), "+120");
1248 }
1249
1250 #[test]
1251 fn test_session_duration_formatting() {
1252 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1253 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1254 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1255 }
1256
1257 #[test]
1258 fn test_session_id_pending_from_pid() {
1259 let id = SessionId::pending_from_pid(12345);
1260 assert_eq!(id.as_str(), "pending-12345");
1261 assert!(id.is_pending());
1262 assert_eq!(id.pending_pid(), Some(12345));
1263 }
1264
1265 #[test]
1266 fn test_session_id_is_pending_true() {
1267 let id = SessionId::new("pending-99999");
1268 assert!(id.is_pending());
1269 }
1270
1271 #[test]
1272 fn test_session_id_is_pending_false() {
1273 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1274 assert!(!id.is_pending());
1275 }
1276
1277 #[test]
1278 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1279 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1280 assert_eq!(id.pending_pid(), None);
1281 }
1282
1283 #[test]
1284 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1285 let id = SessionId::new("pending-not-a-number");
1286 assert_eq!(id.pending_pid(), None);
1287 }
1288}