1use crate::hook::is_interactive_tool;
4use crate::{AgentType, ContextUsage, HookEventType, Model, Money, TokenCount};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::VecDeque;
8use std::fmt;
9use std::path::{Path, PathBuf};
10use tracing::debug;
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct SessionId(String);
23
24pub const PENDING_SESSION_PREFIX: &str = "pending-";
26
27impl SessionId {
28 pub fn new(id: impl Into<String>) -> Self {
33 Self(id.into())
34 }
35
36 pub fn pending_from_pid(pid: u32) -> Self {
43 Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
44 }
45
46 pub fn is_pending(&self) -> bool {
48 self.0.starts_with(PENDING_SESSION_PREFIX)
49 }
50
51 pub fn pending_pid(&self) -> Option<u32> {
55 if !self.is_pending() {
56 return None;
57 }
58 self.0
59 .strip_prefix(PENDING_SESSION_PREFIX)
60 .and_then(|s| s.parse().ok())
61 }
62
63 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67
68 pub fn short(&self) -> &str {
72 self.0.get(..8).unwrap_or(&self.0)
73 }
74}
75
76impl fmt::Display for SessionId {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 write!(f, "{}", self.0)
79 }
80}
81
82impl From<String> for SessionId {
83 fn from(s: String) -> Self {
84 Self(s)
85 }
86}
87
88impl From<&str> for SessionId {
89 fn from(s: &str) -> Self {
90 Self(s.to_string())
91 }
92}
93
94impl AsRef<str> for SessionId {
95 fn as_ref(&self) -> &str {
96 &self.0
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(transparent)]
106pub struct ToolUseId(String);
107
108impl ToolUseId {
109 pub fn new(id: impl Into<String>) -> Self {
110 Self(id.into())
111 }
112
113 pub fn as_str(&self) -> &str {
114 &self.0
115 }
116}
117
118impl fmt::Display for ToolUseId {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 write!(f, "{}", self.0)
121 }
122}
123
124impl From<String> for ToolUseId {
125 fn from(s: String) -> Self {
126 Self(s)
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(transparent)]
135pub struct TranscriptPath(PathBuf);
136
137impl TranscriptPath {
138 pub fn new(path: impl Into<PathBuf>) -> Self {
139 Self(path.into())
140 }
141
142 pub fn as_path(&self) -> &Path {
143 &self.0
144 }
145
146 pub fn filename(&self) -> Option<&str> {
148 self.0.file_name().and_then(|n| n.to_str())
149 }
150}
151
152impl fmt::Display for TranscriptPath {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 write!(f, "{}", self.0.display())
155 }
156}
157
158impl AsRef<Path> for TranscriptPath {
159 fn as_ref(&self) -> &Path {
160 &self.0
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(tag = "status", rename_all = "snake_case")]
173pub enum SessionStatus {
174 Active,
176
177 Thinking,
179
180 RunningTool {
182 tool_name: String,
184 #[serde(skip_serializing_if = "Option::is_none")]
186 started_at: Option<DateTime<Utc>>,
187 },
188
189 WaitingForPermission {
191 tool_name: String,
193 },
194
195 Idle,
197
198 Stale,
200}
201
202impl SessionStatus {
203 pub fn is_active(&self) -> bool {
205 matches!(
206 self,
207 Self::Active | Self::Thinking | Self::RunningTool { .. }
208 )
209 }
210
211 pub fn needs_attention(&self) -> bool {
213 matches!(self, Self::WaitingForPermission { .. })
214 }
215
216 pub fn is_removable(&self) -> bool {
218 matches!(self, Self::Stale)
219 }
220
221 pub fn label(&self) -> &str {
223 match self {
224 Self::Active => "active",
225 Self::Thinking => "thinking",
226 Self::RunningTool { .. } => "running",
227 Self::WaitingForPermission { .. } => "waiting",
228 Self::Idle => "idle",
229 Self::Stale => "stale",
230 }
231 }
232
233 pub fn tool_name(&self) -> Option<&str> {
235 match self {
236 Self::RunningTool { tool_name, .. } => Some(tool_name.as_str()),
237 Self::WaitingForPermission { tool_name } => Some(tool_name.as_str()),
238 _ => None,
239 }
240 }
241}
242
243impl Default for SessionStatus {
244 fn default() -> Self {
245 Self::Active
246 }
247}
248
249impl fmt::Display for SessionStatus {
250 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251 match self {
252 Self::Active => write!(f, "Active"),
253 Self::Thinking => write!(f, "Thinking..."),
254 Self::RunningTool { tool_name, .. } => write!(f, "Running: {tool_name}"),
255 Self::WaitingForPermission { tool_name } => {
256 write!(f, "Permission: {tool_name}")
257 }
258 Self::Idle => write!(f, "Idle"),
259 Self::Stale => write!(f, "Stale"),
260 }
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
287#[serde(rename_all = "snake_case")]
288pub enum DisplayState {
289 #[default]
291 Working,
292
293 Compacting,
295
296 NeedsInput,
298
299 Idle,
301
302 Stale,
304}
305
306impl DisplayState {
307 const ACTIVITY_THRESHOLD_SECS: i64 = 5;
309
310 const STALE_THRESHOLD_SECS: i64 = 8 * 3600;
312
313 const COMPACTION_HIGH_THRESHOLD: f64 = 70.0;
315
316 const COMPACTION_DROP_THRESHOLD: f64 = 20.0;
318
319 pub fn label(&self) -> &'static str {
321 match self {
322 Self::Working => "working",
323 Self::Compacting => "compacting",
324 Self::NeedsInput => "needs input",
325 Self::Idle => "idle",
326 Self::Stale => "stale",
327 }
328 }
329
330 pub fn icon(&self) -> &'static str {
339 match self {
340 Self::Working => ">",
341 Self::Compacting => "~",
342 Self::NeedsInput => "!",
343 Self::Idle => "-",
344 Self::Stale => "z",
345 }
346 }
347
348 pub fn description(&self) -> &'static str {
350 match self {
351 Self::Working => "Session is actively processing",
352 Self::Compacting => "Working after context reduction",
353 Self::NeedsInput => "Waiting for user input or permission",
354 Self::Idle => "Session idle, awaiting user prompt",
355 Self::Stale => "No activity for extended period",
356 }
357 }
358
359 pub fn should_blink(&self) -> bool {
368 matches!(self, Self::NeedsInput)
369 }
370
371 pub fn from_session(
389 time_since_activity_secs: i64,
390 status: &SessionStatus,
391 context_percentage: f64,
392 previous_context_percentage: Option<f64>,
393 ) -> Self {
394 if time_since_activity_secs > Self::STALE_THRESHOLD_SECS {
396 return Self::Stale;
397 }
398
399 match status {
402 SessionStatus::Stale => return Self::Stale,
403 SessionStatus::Idle => return Self::Idle,
404 _ => {}
405 }
406
407 if status.needs_attention() {
409 return Self::NeedsInput;
410 }
411
412 match status {
416 SessionStatus::Thinking | SessionStatus::RunningTool { .. } => {
417 if let Some(prev_pct) = previous_context_percentage {
419 let dropped = prev_pct - context_percentage;
420 if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
421 && dropped >= Self::COMPACTION_DROP_THRESHOLD
422 {
423 return Self::Compacting;
424 }
425 }
426 return Self::Working;
427 }
428 _ => {}
429 }
430
431 let has_recent_activity = time_since_activity_secs < Self::ACTIVITY_THRESHOLD_SECS;
433
434 if !has_recent_activity {
437 return Self::Idle;
438 }
439
440 if status.is_active() {
442 if let Some(prev_pct) = previous_context_percentage {
444 let dropped = prev_pct - context_percentage;
445 if prev_pct >= Self::COMPACTION_HIGH_THRESHOLD
446 && dropped >= Self::COMPACTION_DROP_THRESHOLD
447 {
448 return Self::Compacting;
449 }
450 }
451 return Self::Working;
452 }
453
454 Self::Idle
456 }
457}
458
459impl fmt::Display for DisplayState {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 write!(f, "{}", self.label())
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
473pub struct SessionDuration {
474 total_ms: u64,
476 api_ms: u64,
478}
479
480impl SessionDuration {
481 pub fn new(total_ms: u64, api_ms: u64) -> Self {
483 Self { total_ms, api_ms }
484 }
485
486 pub fn from_total_ms(total_ms: u64) -> Self {
488 Self { total_ms, api_ms: 0 }
489 }
490
491 pub fn total_ms(&self) -> u64 {
493 self.total_ms
494 }
495
496 pub fn api_ms(&self) -> u64 {
498 self.api_ms
499 }
500
501 pub fn total_seconds(&self) -> f64 {
503 self.total_ms as f64 / 1000.0
504 }
505
506 pub fn overhead_ms(&self) -> u64 {
508 self.total_ms.saturating_sub(self.api_ms)
509 }
510
511 pub fn format(&self) -> String {
515 let secs = self.total_ms / 1000;
516 if secs < 60 {
517 format!("{secs}s")
518 } else if secs < 3600 {
519 let mins = secs / 60;
520 let remaining_secs = secs % 60;
521 if remaining_secs == 0 {
522 format!("{mins}m")
523 } else {
524 format!("{mins}m {remaining_secs}s")
525 }
526 } else {
527 let hours = secs / 3600;
528 let remaining_mins = (secs % 3600) / 60;
529 if remaining_mins == 0 {
530 format!("{hours}h")
531 } else {
532 format!("{hours}h {remaining_mins}m")
533 }
534 }
535 }
536
537 pub fn format_compact(&self) -> String {
539 let secs = self.total_ms / 1000;
540 if secs < 60 {
541 format!("{secs}s")
542 } else if secs < 3600 {
543 let mins = secs / 60;
544 format!("{mins}m")
545 } else {
546 let hours = secs / 3600;
547 format!("{hours}h")
548 }
549 }
550}
551
552impl fmt::Display for SessionDuration {
553 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
554 write!(f, "{}", self.format())
555 }
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
562pub struct LinesChanged {
563 pub added: u64,
565 pub removed: u64,
567}
568
569impl LinesChanged {
570 pub fn new(added: u64, removed: u64) -> Self {
572 Self { added, removed }
573 }
574
575 pub fn net(&self) -> i64 {
577 self.added as i64 - self.removed as i64
578 }
579
580 pub fn churn(&self) -> u64 {
582 self.added.saturating_add(self.removed)
583 }
584
585 pub fn is_empty(&self) -> bool {
587 self.added == 0 && self.removed == 0
588 }
589
590 pub fn format(&self) -> String {
592 format!("+{} -{}", self.added, self.removed)
593 }
594
595 pub fn format_net(&self) -> String {
597 let net = self.net();
598 if net >= 0 {
599 format!("+{net}")
600 } else {
601 format!("{net}")
602 }
603 }
604}
605
606impl fmt::Display for LinesChanged {
607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608 write!(f, "{}", self.format())
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize)]
623pub struct SessionDomain {
624 pub id: SessionId,
626
627 pub agent_type: AgentType,
629
630 pub model: Model,
632
633 pub status: SessionStatus,
635
636 pub context: ContextUsage,
638
639 pub cost: Money,
641
642 pub duration: SessionDuration,
644
645 pub lines_changed: LinesChanged,
647
648 pub started_at: DateTime<Utc>,
650
651 pub last_activity: DateTime<Utc>,
653
654 #[serde(skip_serializing_if = "Option::is_none")]
656 pub working_directory: Option<String>,
657
658 #[serde(skip_serializing_if = "Option::is_none")]
660 pub claude_code_version: Option<String>,
661
662 #[serde(skip_serializing_if = "Option::is_none")]
664 pub tmux_pane: Option<String>,
665}
666
667impl SessionDomain {
668 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
670 let now = Utc::now();
671 Self {
672 id,
673 agent_type,
674 model,
675 status: SessionStatus::Active,
676 context: ContextUsage::new(model.context_window_size()),
677 cost: Money::zero(),
678 duration: SessionDuration::default(),
679 lines_changed: LinesChanged::default(),
680 started_at: now,
681 last_activity: now,
682 working_directory: None,
683 claude_code_version: None,
684 tmux_pane: None,
685 }
686 }
687
688 #[allow(clippy::too_many_arguments)]
690 pub fn from_status_line(
691 session_id: &str,
692 model_id: &str,
693 cost_usd: f64,
694 total_duration_ms: u64,
695 api_duration_ms: u64,
696 lines_added: u64,
697 lines_removed: u64,
698 total_input_tokens: u64,
699 total_output_tokens: u64,
700 context_window_size: u32,
701 current_input_tokens: u64,
702 current_output_tokens: u64,
703 cache_creation_tokens: u64,
704 cache_read_tokens: u64,
705 cwd: Option<&str>,
706 version: Option<&str>,
707 ) -> Self {
708 let model = Model::from_id(model_id);
709
710 let mut session = Self::new(
711 SessionId::new(session_id),
712 AgentType::GeneralPurpose, model,
714 );
715
716 session.cost = Money::from_usd(cost_usd);
717 session.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
718 session.lines_changed = LinesChanged::new(lines_added, lines_removed);
719 session.context = ContextUsage {
720 total_input_tokens: TokenCount::new(total_input_tokens),
721 total_output_tokens: TokenCount::new(total_output_tokens),
722 context_window_size,
723 current_input_tokens: TokenCount::new(current_input_tokens),
724 current_output_tokens: TokenCount::new(current_output_tokens),
725 cache_creation_tokens: TokenCount::new(cache_creation_tokens),
726 cache_read_tokens: TokenCount::new(cache_read_tokens),
727 };
728 session.working_directory = cwd.map(|s| s.to_string());
729 session.claude_code_version = version.map(|s| s.to_string());
730 session.last_activity = Utc::now();
731
732 session
733 }
734
735 #[allow(clippy::too_many_arguments)]
740 pub fn update_from_status_line(
741 &mut self,
742 cost_usd: f64,
743 total_duration_ms: u64,
744 api_duration_ms: u64,
745 lines_added: u64,
746 lines_removed: u64,
747 total_input_tokens: u64,
748 total_output_tokens: u64,
749 current_input_tokens: u64,
750 current_output_tokens: u64,
751 cache_creation_tokens: u64,
752 cache_read_tokens: u64,
753 ) {
754 self.cost = Money::from_usd(cost_usd);
755 self.duration = SessionDuration::new(total_duration_ms, api_duration_ms);
756 self.lines_changed = LinesChanged::new(lines_added, lines_removed);
757 self.context.total_input_tokens = TokenCount::new(total_input_tokens);
758 self.context.total_output_tokens = TokenCount::new(total_output_tokens);
759 self.context.current_input_tokens = TokenCount::new(current_input_tokens);
760 self.context.current_output_tokens = TokenCount::new(current_output_tokens);
761 self.context.cache_creation_tokens = TokenCount::new(cache_creation_tokens);
762 self.context.cache_read_tokens = TokenCount::new(cache_read_tokens);
763 self.last_activity = Utc::now();
764
765 if !matches!(self.status, SessionStatus::WaitingForPermission { .. }) {
767 self.status = SessionStatus::Active;
768 }
769 }
770
771 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
773 self.last_activity = Utc::now();
774
775 match event_type {
776 HookEventType::PreToolUse => {
777 if let Some(name) = tool_name {
778 if is_interactive_tool(name) {
780 self.status = SessionStatus::WaitingForPermission {
781 tool_name: name.to_string(),
782 };
783 } else {
784 self.status = SessionStatus::RunningTool {
785 tool_name: name.to_string(),
786 started_at: Some(Utc::now()),
787 };
788 }
789 }
790 }
791 HookEventType::PostToolUse => {
792 self.status = SessionStatus::Thinking;
793 }
794 _ => {}
795 }
796 }
797
798 pub fn set_waiting_for_permission(&mut self, tool_name: &str) {
800 self.status = SessionStatus::WaitingForPermission {
801 tool_name: tool_name.to_string(),
802 };
803 self.last_activity = Utc::now();
804 }
805
806 pub fn age(&self) -> chrono::Duration {
808 Utc::now().signed_duration_since(self.started_at)
809 }
810
811 pub fn time_since_activity(&self) -> chrono::Duration {
813 Utc::now().signed_duration_since(self.last_activity)
814 }
815
816 pub fn is_stale(&self) -> bool {
820 self.time_since_activity() > chrono::Duration::hours(8)
821 }
822
823 pub fn needs_context_attention(&self) -> bool {
825 self.context.is_warning() || self.context.is_critical()
826 }
827}
828
829impl Default for SessionDomain {
830 fn default() -> Self {
831 Self::new(
832 SessionId::new("unknown"),
833 AgentType::default(),
834 Model::default(),
835 )
836 }
837}
838
839#[derive(Debug, Clone)]
845pub struct ToolUsageRecord {
846 pub tool_name: String,
848 pub tool_use_id: Option<ToolUseId>,
850 pub timestamp: DateTime<Utc>,
852}
853
854#[derive(Debug, Clone)]
859pub struct SessionInfrastructure {
860 pub pid: Option<u32>,
862
863 pub process_start_time: Option<u64>,
866
867 pub socket_path: Option<PathBuf>,
869
870 pub transcript_path: Option<TranscriptPath>,
872
873 pub recent_tools: VecDeque<ToolUsageRecord>,
875
876 pub update_count: u64,
878
879 pub hook_event_count: u64,
881
882 pub last_error: Option<String>,
884}
885
886impl SessionInfrastructure {
887 const MAX_TOOL_HISTORY: usize = 50;
889
890 pub fn new() -> Self {
892 Self {
893 pid: None,
894 process_start_time: None,
895 socket_path: None,
896 transcript_path: None,
897 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
898 update_count: 0,
899 hook_event_count: 0,
900 last_error: None,
901 }
902 }
903
904 pub fn set_pid(&mut self, pid: u32) {
917 if pid == 0 {
919 return;
920 }
921
922 if self.pid == Some(pid) {
924 return;
925 }
926
927 if let Some(start_time) = read_process_start_time(pid) {
930 self.pid = Some(pid);
931 self.process_start_time = Some(start_time);
932 } else {
933 debug!(
934 pid = pid,
935 "PID validation failed - process may have exited or is inaccessible"
936 );
937 }
938 }
939
940 pub fn is_process_alive(&self) -> bool {
950 let Some(pid) = self.pid else {
951 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
953 return true;
954 };
955
956 let Some(expected_start_time) = self.process_start_time else {
957 let exists = procfs::process::Process::new(pid as i32).is_ok();
959 debug!(pid, exists, "is_process_alive: no start_time, checking procfs only");
960 return exists;
961 };
962
963 match read_process_start_time(pid) {
965 Some(current_start_time) => {
966 let alive = current_start_time == expected_start_time;
967 if !alive {
968 debug!(
969 pid,
970 expected_start_time,
971 current_start_time,
972 "is_process_alive: start time MISMATCH - PID reused?"
973 );
974 }
975 alive
976 }
977 None => {
978 debug!(pid, expected_start_time, "is_process_alive: process NOT FOUND in /proc");
979 false
980 }
981 }
982 }
983
984 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
986 let record = ToolUsageRecord {
987 tool_name: tool_name.to_string(),
988 tool_use_id,
989 timestamp: Utc::now(),
990 };
991
992 self.recent_tools.push_back(record);
993
994 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
996 self.recent_tools.pop_front();
997 }
998
999 self.hook_event_count += 1;
1000 }
1001
1002 pub fn record_update(&mut self) {
1004 self.update_count += 1;
1005 }
1006
1007 pub fn record_error(&mut self, error: &str) {
1009 self.last_error = Some(error.to_string());
1010 }
1011
1012 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1014 self.recent_tools.back()
1015 }
1016
1017 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1019 self.recent_tools.iter().rev()
1020 }
1021}
1022
1023fn read_process_start_time(pid: u32) -> Option<u64> {
1030 let process = procfs::process::Process::new(pid as i32).ok()?;
1031 let stat = process.stat().ok()?;
1032 Some(stat.starttime)
1033}
1034
1035impl Default for SessionInfrastructure {
1036 fn default() -> Self {
1037 Self::new()
1038 }
1039}
1040
1041#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1050pub struct SessionView {
1051 pub id: SessionId,
1053
1054 pub id_short: String,
1056
1057 pub agent_type: String,
1059
1060 pub model: String,
1062
1063 pub status: String,
1065
1066 pub status_detail: Option<String>,
1068
1069 pub context_percentage: f64,
1071
1072 pub context_display: String,
1074
1075 pub context_warning: bool,
1077
1078 pub context_critical: bool,
1080
1081 pub cost_display: String,
1083
1084 pub cost_usd: f64,
1086
1087 pub duration_display: String,
1089
1090 pub duration_seconds: f64,
1092
1093 pub lines_display: String,
1095
1096 pub working_directory: Option<String>,
1098
1099 pub is_stale: bool,
1101
1102 pub needs_attention: bool,
1104
1105 pub last_activity_display: String,
1107
1108 pub age_display: String,
1110
1111 pub started_at: String,
1113
1114 pub last_activity: String,
1116
1117 pub tmux_pane: Option<String>,
1119
1120 pub display_state: DisplayState,
1122}
1123
1124impl SessionView {
1125 pub fn from_domain(session: &SessionDomain) -> Self {
1127 let now = Utc::now();
1128 let since_activity = now.signed_duration_since(session.last_activity);
1129 let age = now.signed_duration_since(session.started_at);
1130
1131 Self {
1132 id: session.id.clone(),
1133 id_short: session.id.short().to_string(),
1134 agent_type: session.agent_type.short_name().to_string(),
1135 model: session.model.display_name().to_string(),
1136 status: session.status.label().to_string(),
1137 status_detail: session.status.tool_name().map(|s| s.to_string()),
1138 context_percentage: session.context.usage_percentage(),
1139 context_display: session.context.format(),
1140 context_warning: session.context.is_warning(),
1141 context_critical: session.context.is_critical(),
1142 cost_display: session.cost.format(),
1143 cost_usd: session.cost.as_usd(),
1144 duration_display: session.duration.format(),
1145 duration_seconds: session.duration.total_seconds(),
1146 lines_display: session.lines_changed.format(),
1147 working_directory: session.working_directory.clone().map(|p| {
1148 if p.len() > 30 {
1150 format!("...{}", &p[p.len().saturating_sub(27)..])
1151 } else {
1152 p
1153 }
1154 }),
1155 is_stale: session.is_stale(),
1156 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1157 last_activity_display: format_duration(since_activity),
1158 age_display: format_duration(age),
1159 started_at: session.started_at.to_rfc3339(),
1160 last_activity: session.last_activity.to_rfc3339(),
1161 tmux_pane: session.tmux_pane.clone(),
1162 display_state: DisplayState::from_session(
1163 since_activity.num_seconds(),
1164 &session.status,
1165 session.context.usage_percentage(),
1166 None, ),
1168 }
1169 }
1170}
1171
1172impl From<&SessionDomain> for SessionView {
1173 fn from(session: &SessionDomain) -> Self {
1174 Self::from_domain(session)
1175 }
1176}
1177
1178fn format_duration(duration: chrono::Duration) -> String {
1180 let secs = duration.num_seconds();
1181 if secs < 0 {
1182 return "now".to_string();
1183 }
1184 if secs < 60 {
1185 format!("{secs}s ago")
1186 } else if secs < 3600 {
1187 let mins = secs / 60;
1188 format!("{mins}m ago")
1189 } else if secs < 86400 {
1190 let hours = secs / 3600;
1191 format!("{hours}h ago")
1192 } else {
1193 let days = secs / 86400;
1194 format!("{days}d ago")
1195 }
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use super::*;
1201
1202 fn create_test_session(id: &str) -> SessionDomain {
1204 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1205 }
1206
1207 #[test]
1208 fn test_session_id_short() {
1209 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1210 assert_eq!(id.short(), "8e11bfb5");
1211 }
1212
1213 #[test]
1214 fn test_session_id_short_short_id() {
1215 let id = SessionId::new("abc");
1216 assert_eq!(id.short(), "abc");
1217 }
1218
1219 #[test]
1220 fn test_session_status_display() {
1221 let status = SessionStatus::RunningTool {
1222 tool_name: "Bash".to_string(),
1223 started_at: None,
1224 };
1225 assert_eq!(format!("{status}"), "Running: Bash");
1226 }
1227
1228 #[test]
1229 fn test_session_domain_creation() {
1230 let session = SessionDomain::new(
1231 SessionId::new("test-123"),
1232 AgentType::GeneralPurpose,
1233 Model::Opus45,
1234 );
1235 assert_eq!(session.id.as_str(), "test-123");
1236 assert_eq!(session.model, Model::Opus45);
1237 assert!(session.cost.is_zero());
1238 }
1239
1240 #[test]
1241 fn test_session_view_from_domain() {
1242 let session = SessionDomain::new(
1243 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1244 AgentType::Explore,
1245 Model::Sonnet4,
1246 );
1247 let view = SessionView::from_domain(&session);
1248
1249 assert_eq!(view.id_short, "8e11bfb5");
1250 assert_eq!(view.agent_type, "explore");
1251 assert_eq!(view.model, "Sonnet 4");
1252 }
1253
1254 #[test]
1255 fn test_lines_changed() {
1256 let lines = LinesChanged::new(150, 30);
1257 assert_eq!(lines.net(), 120);
1258 assert_eq!(lines.churn(), 180);
1259 assert_eq!(lines.format(), "+150 -30");
1260 assert_eq!(lines.format_net(), "+120");
1261 }
1262
1263 #[test]
1264 fn test_session_duration_formatting() {
1265 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1266 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1267 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1268 }
1269
1270 #[test]
1271 fn test_session_id_pending_from_pid() {
1272 let id = SessionId::pending_from_pid(12345);
1273 assert_eq!(id.as_str(), "pending-12345");
1274 assert!(id.is_pending());
1275 assert_eq!(id.pending_pid(), Some(12345));
1276 }
1277
1278 #[test]
1279 fn test_session_id_is_pending_true() {
1280 let id = SessionId::new("pending-99999");
1281 assert!(id.is_pending());
1282 }
1283
1284 #[test]
1285 fn test_session_id_is_pending_false() {
1286 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1287 assert!(!id.is_pending());
1288 }
1289
1290 #[test]
1291 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1292 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1293 assert_eq!(id.pending_pid(), None);
1294 }
1295
1296 #[test]
1297 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1298 let id = SessionId::new("pending-not-a-number");
1299 assert_eq!(id.pending_pid(), None);
1300 }
1301
1302 #[test]
1303 fn test_apply_hook_event_interactive_tool() {
1304 let mut session = create_test_session("test-interactive");
1305
1306 session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1308
1309 assert!(matches!(
1310 session.status,
1311 SessionStatus::WaitingForPermission { ref tool_name } if tool_name == "AskUserQuestion"
1312 ));
1313
1314 session.apply_hook_event(HookEventType::PostToolUse, None);
1316 assert!(matches!(session.status, SessionStatus::Thinking));
1317 }
1318
1319 #[test]
1320 fn test_apply_hook_event_enter_plan_mode() {
1321 let mut session = create_test_session("test-plan");
1322
1323 session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1325
1326 assert!(matches!(
1327 session.status,
1328 SessionStatus::WaitingForPermission { ref tool_name } if tool_name == "EnterPlanMode"
1329 ));
1330 }
1331
1332 #[test]
1333 fn test_apply_hook_event_standard_tool() {
1334 let mut session = create_test_session("test-standard");
1335
1336 session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1338
1339 assert!(matches!(
1340 session.status,
1341 SessionStatus::RunningTool { ref tool_name, .. } if tool_name == "Bash"
1342 ));
1343
1344 session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1346 assert!(matches!(session.status, SessionStatus::Thinking));
1347 }
1348
1349 #[test]
1350 fn test_apply_hook_event_none_tool_name() {
1351 let mut session = create_test_session("test-none");
1352 let original_status = session.status.clone();
1353
1354 session.apply_hook_event(HookEventType::PreToolUse, None);
1356
1357 assert_eq!(
1358 session.status, original_status,
1359 "PreToolUse with None tool_name should not change status"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_apply_hook_event_empty_tool_name() {
1365 let mut session = create_test_session("test-empty");
1366
1367 session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1370
1371 assert!(matches!(
1372 session.status,
1373 SessionStatus::RunningTool { ref tool_name, .. } if tool_name.is_empty()
1374 ));
1375 }
1376}