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