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) -> bool {
679 self.cost = Money::from_usd(data.cost_usd);
680 self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
681 self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
682 self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
683 self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
684 self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
685 self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
686 self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
687 self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
688 self.last_activity = Utc::now();
689
690 if self.status != SessionStatus::AttentionNeeded {
693 self.status = SessionStatus::Working;
694 }
695
696 let cwd_changed = match (&data.cwd, &self.working_directory) {
698 (Some(new_cwd), Some(old_cwd)) => new_cwd != old_cwd,
699 (Some(_), None) => true,
700 _ => false,
701 };
702 if cwd_changed {
703 self.working_directory = data.cwd.clone();
704 }
705 cwd_changed
706 }
707
708 pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
710 self.last_activity = Utc::now();
711
712 match event_type {
713 HookEventType::PreToolUse => {
714 if let Some(name) = tool_name {
715 if is_interactive_tool(name) {
716 self.status = SessionStatus::AttentionNeeded;
717 self.current_activity = Some(ActivityDetail::new(name));
718 } else {
719 self.status = SessionStatus::Working;
720 self.current_activity = Some(ActivityDetail::new(name));
721 }
722 }
723 }
724 HookEventType::PostToolUse | HookEventType::PostToolUseFailure => {
725 self.status = SessionStatus::Working;
726 self.current_activity = Some(ActivityDetail::thinking());
727 }
728 HookEventType::UserPromptSubmit => {
729 self.status = SessionStatus::Working;
730 self.current_activity = None;
731 }
733 HookEventType::Stop => {
734 self.status = SessionStatus::Idle;
735 self.current_activity = None;
736 }
737 HookEventType::SessionStart => {
738 self.status = SessionStatus::Idle;
739 self.current_activity = None;
740 }
741 HookEventType::SessionEnd => {
742 self.status = SessionStatus::Idle;
744 self.current_activity = None;
745 }
746 HookEventType::PreCompact => {
747 self.status = SessionStatus::Working;
748 self.current_activity = Some(ActivityDetail::with_context("Compacting"));
749 }
750 HookEventType::Setup => {
751 self.status = SessionStatus::Working;
752 self.current_activity = Some(ActivityDetail::with_context("Setup"));
753 }
754 HookEventType::Notification => {
755 }
758 HookEventType::SubagentStart | HookEventType::SubagentStop => {
759 self.status = SessionStatus::Working;
761 }
762 }
763 }
764
765 pub fn set_first_prompt(&mut self, prompt: &str) {
767 if self.first_prompt.is_none() && !prompt.is_empty() {
768 self.first_prompt = Some(prompt.to_string());
769 }
770 }
771
772 pub fn apply_notification(&mut self, notification_type: Option<&str>) {
774 self.last_activity = Utc::now();
775
776 match notification_type {
777 Some("permission_prompt") => {
778 self.status = SessionStatus::AttentionNeeded;
779 self.current_activity = Some(ActivityDetail::with_context("Permission"));
780 }
781 Some("idle_prompt") => {
782 self.status = SessionStatus::Idle;
783 self.current_activity = None;
784 }
785 Some("elicitation_dialog") => {
786 self.status = SessionStatus::AttentionNeeded;
787 self.current_activity = Some(ActivityDetail::with_context("MCP Input"));
788 }
789 _ => {
790 }
792 }
793 }
794
795 pub fn age(&self) -> chrono::Duration {
797 Utc::now().signed_duration_since(self.started_at)
798 }
799
800 pub fn time_since_activity(&self) -> chrono::Duration {
802 Utc::now().signed_duration_since(self.last_activity)
803 }
804
805 pub fn needs_context_attention(&self) -> bool {
807 self.context.is_warning() || self.context.is_critical()
808 }
809}
810
811impl Default for SessionDomain {
812 fn default() -> Self {
813 Self::new(
814 SessionId::new("unknown"),
815 AgentType::default(),
816 Model::default(),
817 )
818 }
819}
820
821#[derive(Debug, Clone)]
827pub struct ToolUsageRecord {
828 pub tool_name: String,
830 pub tool_use_id: Option<ToolUseId>,
832 pub timestamp: DateTime<Utc>,
834}
835
836#[derive(Debug, Clone)]
841pub struct SessionInfrastructure {
842 pub pid: Option<u32>,
844
845 pub process_start_time: Option<u64>,
848
849 pub socket_path: Option<PathBuf>,
851
852 pub transcript_path: Option<TranscriptPath>,
854
855 pub recent_tools: VecDeque<ToolUsageRecord>,
857
858 pub update_count: u64,
860
861 pub hook_event_count: u64,
863
864 pub last_error: Option<String>,
866}
867
868impl SessionInfrastructure {
869 const MAX_TOOL_HISTORY: usize = 50;
871
872 pub fn new() -> Self {
874 Self {
875 pid: None,
876 process_start_time: None,
877 socket_path: None,
878 transcript_path: None,
879 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
880 update_count: 0,
881 hook_event_count: 0,
882 last_error: None,
883 }
884 }
885
886 pub fn set_pid(&mut self, pid: u32) {
899 if pid == 0 {
901 return;
902 }
903
904 if self.pid == Some(pid) {
906 return;
907 }
908
909 if let Some(start_time) = read_process_start_time(pid) {
912 self.pid = Some(pid);
913 self.process_start_time = Some(start_time);
914 } else {
915 debug!(
916 pid = pid,
917 "PID validation failed - process may have exited or is inaccessible"
918 );
919 }
920 }
921
922 pub fn is_process_alive(&self) -> bool {
932 let Some(pid) = self.pid else {
933 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
935 return true;
936 };
937
938 let Some(expected_start_time) = self.process_start_time else {
939 let exists = procfs::process::Process::new(pid as i32).is_ok();
941 debug!(
942 pid,
943 exists, "is_process_alive: no start_time, checking procfs only"
944 );
945 return exists;
946 };
947
948 match read_process_start_time(pid) {
950 Some(current_start_time) => {
951 let alive = current_start_time == expected_start_time;
952 if !alive {
953 debug!(
954 pid,
955 expected_start_time,
956 current_start_time,
957 "is_process_alive: start time MISMATCH - PID reused?"
958 );
959 }
960 alive
961 }
962 None => {
963 debug!(
964 pid,
965 expected_start_time, "is_process_alive: process NOT FOUND in /proc"
966 );
967 false
968 }
969 }
970 }
971
972 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
974 let record = ToolUsageRecord {
975 tool_name: tool_name.to_string(),
976 tool_use_id,
977 timestamp: Utc::now(),
978 };
979
980 self.recent_tools.push_back(record);
981
982 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
984 self.recent_tools.pop_front();
985 }
986
987 self.hook_event_count += 1;
988 }
989
990 pub fn record_update(&mut self) {
992 self.update_count += 1;
993 }
994
995 pub fn record_error(&mut self, error: &str) {
997 self.last_error = Some(error.to_string());
998 }
999
1000 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1002 self.recent_tools.back()
1003 }
1004
1005 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1007 self.recent_tools.iter().rev()
1008 }
1009}
1010
1011fn read_process_start_time(pid: u32) -> Option<u64> {
1018 let process = procfs::process::Process::new(pid as i32).ok()?;
1019 let stat = process.stat().ok()?;
1020 Some(stat.starttime)
1021}
1022
1023impl Default for SessionInfrastructure {
1024 fn default() -> Self {
1025 Self::new()
1026 }
1027}
1028
1029#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1038pub struct SessionView {
1039 pub id: SessionId,
1041
1042 pub id_short: String,
1044
1045 pub agent_type: String,
1047
1048 pub model: String,
1050
1051 pub status: SessionStatus,
1053
1054 pub status_label: String,
1056
1057 pub activity_detail: Option<String>,
1059
1060 pub should_blink: bool,
1062
1063 pub status_icon: String,
1065
1066 pub context_percentage: f64,
1068
1069 pub context_display: String,
1071
1072 pub context_warning: bool,
1074
1075 pub context_critical: bool,
1077
1078 pub cost_display: String,
1080
1081 pub cost_usd: f64,
1083
1084 pub duration_display: String,
1086
1087 pub duration_seconds: f64,
1089
1090 pub lines_display: String,
1092
1093 pub working_directory: Option<String>,
1095
1096 pub needs_attention: bool,
1098
1099 pub last_activity_display: String,
1101
1102 pub age_display: String,
1104
1105 pub started_at: String,
1107
1108 pub last_activity: String,
1110
1111 pub tmux_pane: Option<String>,
1113
1114 #[serde(skip_serializing_if = "Option::is_none")]
1116 pub project_root: Option<String>,
1117
1118 #[serde(skip_serializing_if = "Option::is_none")]
1120 pub worktree_path: Option<String>,
1121
1122 #[serde(skip_serializing_if = "Option::is_none")]
1124 pub worktree_branch: Option<String>,
1125
1126 #[serde(skip_serializing_if = "Option::is_none")]
1128 pub parent_session_id: Option<SessionId>,
1129
1130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1132 pub child_session_ids: Vec<SessionId>,
1133
1134 #[serde(skip_serializing_if = "Option::is_none")]
1136 pub first_prompt: Option<String>,
1137}
1138
1139impl SessionView {
1140 pub fn from_domain(session: &SessionDomain) -> Self {
1142 let now = Utc::now();
1143 let since_activity = now.signed_duration_since(session.last_activity);
1144 let age = now.signed_duration_since(session.started_at);
1145
1146 Self {
1147 id: session.id.clone(),
1148 id_short: session.id.short().to_string(),
1149 agent_type: session.agent_type.short_name().to_string(),
1150 model: if session.model.is_unknown() {
1151 session
1152 .model_display_override
1153 .clone()
1154 .unwrap_or_else(|| session.model.display_name().to_string())
1155 } else {
1156 session.model.display_name().to_string()
1157 },
1158 status: session.status,
1159 status_label: session.status.label().to_string(),
1160 activity_detail: session
1161 .current_activity
1162 .as_ref()
1163 .map(|a| a.display().into_owned()),
1164 should_blink: session.status.should_blink(),
1165 status_icon: session.status.icon().to_string(),
1166 context_percentage: session.context.usage_percentage(),
1167 context_display: session.context.format(),
1168 context_warning: session.context.is_warning(),
1169 context_critical: session.context.is_critical(),
1170 cost_display: session.cost.format(),
1171 cost_usd: session.cost.as_usd(),
1172 duration_display: session.duration.format(),
1173 duration_seconds: session.duration.total_seconds(),
1174 lines_display: session.lines_changed.format(),
1175 working_directory: session.working_directory.clone().map(|p| {
1176 if p.len() > 30 {
1178 format!("...{}", &p[p.len().saturating_sub(27)..])
1179 } else {
1180 p
1181 }
1182 }),
1183 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1184 last_activity_display: format_duration(since_activity),
1185 age_display: format_duration(age),
1186 started_at: session.started_at.to_rfc3339(),
1187 last_activity: session.last_activity.to_rfc3339(),
1188 tmux_pane: session.tmux_pane.clone(),
1189 project_root: session.project_root.clone(),
1190 worktree_path: session.worktree_path.clone(),
1191 worktree_branch: session.worktree_branch.clone(),
1192 parent_session_id: session.parent_session_id.clone(),
1193 child_session_ids: session.child_session_ids.clone(),
1194 first_prompt: session.first_prompt.clone(),
1195 }
1196 }
1197}
1198
1199impl From<&SessionDomain> for SessionView {
1200 fn from(session: &SessionDomain) -> Self {
1201 Self::from_domain(session)
1202 }
1203}
1204
1205fn format_duration(duration: chrono::Duration) -> String {
1207 let secs = duration.num_seconds();
1208 if secs < 0 {
1209 return "now".to_string();
1210 }
1211 if secs < 60 {
1212 format!("{secs}s ago")
1213 } else if secs < 3600 {
1214 let mins = secs / 60;
1215 format!("{mins}m ago")
1216 } else if secs < 86400 {
1217 let hours = secs / 3600;
1218 format!("{hours}h ago")
1219 } else {
1220 let days = secs / 86400;
1221 format!("{days}d ago")
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 fn create_test_session(id: &str) -> SessionDomain {
1231 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1232 }
1233
1234 #[test]
1235 fn test_session_id_short() {
1236 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1237 assert_eq!(id.short(), "8e11bfb5");
1238 }
1239
1240 #[test]
1241 fn test_session_id_short_short_id() {
1242 let id = SessionId::new("abc");
1243 assert_eq!(id.short(), "abc");
1244 }
1245
1246 #[test]
1247 fn test_session_status_display() {
1248 let status = SessionStatus::Working;
1249 assert_eq!(format!("{status}"), "Working");
1250 }
1251
1252 #[test]
1253 fn test_session_domain_creation() {
1254 let session = SessionDomain::new(
1255 SessionId::new("test-123"),
1256 AgentType::GeneralPurpose,
1257 Model::Opus45,
1258 );
1259 assert_eq!(session.id.as_str(), "test-123");
1260 assert_eq!(session.model, Model::Opus45);
1261 assert!(session.cost.is_zero());
1262 }
1263
1264 #[test]
1265 fn test_session_view_from_domain() {
1266 let session = SessionDomain::new(
1267 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1268 AgentType::Explore,
1269 Model::Sonnet4,
1270 );
1271 let view = SessionView::from_domain(&session);
1272
1273 assert_eq!(view.id_short, "8e11bfb5");
1274 assert_eq!(view.agent_type, "explore");
1275 assert_eq!(view.model, "Sonnet 4");
1276 }
1277
1278 #[test]
1279 fn test_session_view_unknown_model_with_override() {
1280 let mut session = SessionDomain::new(
1281 SessionId::new("test-override"),
1282 AgentType::GeneralPurpose,
1283 Model::Unknown,
1284 );
1285 session.model_display_override = Some("GPT-4o".to_string());
1286
1287 let view = SessionView::from_domain(&session);
1288 assert_eq!(view.model, "GPT-4o");
1289 }
1290
1291 #[test]
1292 fn test_session_view_unknown_model_without_override() {
1293 let session = SessionDomain::new(
1294 SessionId::new("test-no-override"),
1295 AgentType::GeneralPurpose,
1296 Model::Unknown,
1297 );
1298
1299 let view = SessionView::from_domain(&session);
1300 assert_eq!(view.model, "Unknown");
1301 }
1302
1303 #[test]
1304 fn test_session_view_known_model_ignores_override() {
1305 let mut session = SessionDomain::new(
1306 SessionId::new("test-known"),
1307 AgentType::GeneralPurpose,
1308 Model::Opus46,
1309 );
1310 session.model_display_override = Some("something else".to_string());
1312
1313 let view = SessionView::from_domain(&session);
1314 assert_eq!(view.model, "Opus 4.6");
1315 }
1316
1317 #[test]
1318 fn test_lines_changed() {
1319 let lines = LinesChanged::new(150, 30);
1320 assert_eq!(lines.net(), 120);
1321 assert_eq!(lines.churn(), 180);
1322 assert_eq!(lines.format(), "+150 -30");
1323 assert_eq!(lines.format_net(), "+120");
1324 }
1325
1326 #[test]
1327 fn test_session_duration_formatting() {
1328 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1329 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1330 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1331 }
1332
1333 #[test]
1334 fn test_session_id_pending_from_pid() {
1335 let id = SessionId::pending_from_pid(12345);
1336 assert_eq!(id.as_str(), "pending-12345");
1337 assert!(id.is_pending());
1338 assert_eq!(id.pending_pid(), Some(12345));
1339 }
1340
1341 #[test]
1342 fn test_session_id_is_pending_true() {
1343 let id = SessionId::new("pending-99999");
1344 assert!(id.is_pending());
1345 }
1346
1347 #[test]
1348 fn test_session_id_is_pending_false() {
1349 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1350 assert!(!id.is_pending());
1351 }
1352
1353 #[test]
1354 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1355 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1356 assert_eq!(id.pending_pid(), None);
1357 }
1358
1359 #[test]
1360 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1361 let id = SessionId::new("pending-not-a-number");
1362 assert_eq!(id.pending_pid(), None);
1363 }
1364
1365 #[test]
1366 fn test_apply_hook_event_interactive_tool() {
1367 let mut session = create_test_session("test-interactive");
1368
1369 session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1371
1372 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1373 assert_eq!(
1374 session
1375 .current_activity
1376 .as_ref()
1377 .map(|a| a.display())
1378 .as_deref(),
1379 Some("AskUserQuestion")
1380 );
1381
1382 session.apply_hook_event(HookEventType::PostToolUse, None);
1384 assert_eq!(session.status, SessionStatus::Working);
1385 }
1386
1387 #[test]
1388 fn test_apply_hook_event_enter_plan_mode() {
1389 let mut session = create_test_session("test-plan");
1390
1391 session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1393
1394 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1395 assert_eq!(
1396 session
1397 .current_activity
1398 .as_ref()
1399 .map(|a| a.display())
1400 .as_deref(),
1401 Some("EnterPlanMode")
1402 );
1403 }
1404
1405 #[test]
1406 fn test_apply_hook_event_standard_tool() {
1407 let mut session = create_test_session("test-standard");
1408
1409 session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1411
1412 assert_eq!(session.status, SessionStatus::Working);
1413 assert_eq!(
1414 session
1415 .current_activity
1416 .as_ref()
1417 .map(|a| a.display())
1418 .as_deref(),
1419 Some("Bash")
1420 );
1421
1422 session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1424 assert_eq!(session.status, SessionStatus::Working);
1425 }
1426
1427 #[test]
1428 fn test_apply_hook_event_none_tool_name() {
1429 let mut session = create_test_session("test-none");
1430 let original_status = session.status;
1431
1432 session.apply_hook_event(HookEventType::PreToolUse, None);
1434
1435 assert_eq!(
1436 session.status, original_status,
1437 "PreToolUse with None tool_name should not change status"
1438 );
1439 }
1440
1441 #[test]
1442 fn test_apply_hook_event_empty_tool_name() {
1443 let mut session = create_test_session("test-empty");
1444
1445 session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1448
1449 assert_eq!(session.status, SessionStatus::Working);
1450 }
1451
1452 #[test]
1453 fn test_activity_detail_creation() {
1454 let detail = ActivityDetail::new("Bash");
1455 assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1456 assert!(detail.started_at <= Utc::now());
1457 assert!(detail.context.is_none());
1458 }
1459
1460 #[test]
1461 fn test_activity_detail_with_context() {
1462 let detail = ActivityDetail::with_context("Compacting");
1463 assert!(detail.tool_name.is_none());
1464 assert_eq!(detail.context.as_deref(), Some("Compacting"));
1465 }
1466
1467 #[test]
1468 fn test_activity_detail_display() {
1469 let detail = ActivityDetail::new("Read");
1470 assert_eq!(detail.display(), "Read");
1471
1472 let context_detail = ActivityDetail::with_context("Setup");
1473 assert_eq!(context_detail.display(), "Setup");
1474 }
1475
1476 #[test]
1477 fn test_new_session_status_variants() {
1478 let idle = SessionStatus::Idle;
1480 let working = SessionStatus::Working;
1481 let attention = SessionStatus::AttentionNeeded;
1482
1483 assert_eq!(idle.label(), "idle");
1484 assert_eq!(working.label(), "working");
1485 assert_eq!(attention.label(), "needs input");
1486 }
1487
1488 #[test]
1489 fn test_session_status_should_blink() {
1490 assert!(!SessionStatus::Idle.should_blink());
1491 assert!(!SessionStatus::Working.should_blink());
1492 assert!(SessionStatus::AttentionNeeded.should_blink());
1493 }
1494
1495 #[test]
1496 fn test_session_status_icons() {
1497 assert_eq!(SessionStatus::Idle.icon(), "-");
1498 assert_eq!(SessionStatus::Working.icon(), ">");
1499 assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1500 }
1501
1502 #[test]
1503 fn test_session_domain_new_fields_default() {
1504 let session = create_test_session("test-defaults");
1505 assert!(session.project_root.is_none());
1506 assert!(session.worktree_path.is_none());
1507 assert!(session.worktree_branch.is_none());
1508 assert!(session.parent_session_id.is_none());
1509 assert!(session.child_session_ids.is_empty());
1510 }
1511
1512 #[test]
1513 fn test_session_view_includes_new_fields() {
1514 let mut session = create_test_session("test-view-fields");
1515 session.project_root = Some("/home/user/project".to_string());
1516 session.worktree_path = Some("/home/user/worktree".to_string());
1517 session.worktree_branch = Some("feature-x".to_string());
1518 session.parent_session_id = Some(SessionId::new("parent-123"));
1519 session.child_session_ids = vec![SessionId::new("child-1"), SessionId::new("child-2")];
1520
1521 let view = SessionView::from_domain(&session);
1522
1523 assert_eq!(view.project_root, Some("/home/user/project".to_string()));
1524 assert_eq!(view.worktree_path, Some("/home/user/worktree".to_string()));
1525 assert_eq!(view.worktree_branch, Some("feature-x".to_string()));
1526 assert_eq!(view.parent_session_id, Some(SessionId::new("parent-123")));
1527 assert_eq!(view.child_session_ids.len(), 2);
1528 assert_eq!(view.child_session_ids[0].as_str(), "child-1");
1529 assert_eq!(view.child_session_ids[1].as_str(), "child-2");
1530 }
1531
1532 fn make_status_data(cwd: Option<&str>) -> StatusLineData {
1537 StatusLineData {
1538 session_id: "test".to_string(),
1539 model_id: "claude-sonnet-4-20250514".to_string(),
1540 model_display_name: None,
1541 cost_usd: 0.10,
1542 total_duration_ms: 1000,
1543 api_duration_ms: 500,
1544 lines_added: 10,
1545 lines_removed: 5,
1546 total_input_tokens: 1000,
1547 total_output_tokens: 500,
1548 context_window_size: 200_000,
1549 current_input_tokens: 800,
1550 current_output_tokens: 400,
1551 cache_creation_tokens: 0,
1552 cache_read_tokens: 0,
1553 cwd: cwd.map(|s| s.to_string()),
1554 version: None,
1555 }
1556 }
1557
1558 #[test]
1559 fn test_update_from_status_line_cwd_changed() {
1560 let mut session = SessionDomain::new(
1561 SessionId::new("test"),
1562 AgentType::GeneralPurpose,
1563 Model::Sonnet4,
1564 );
1565 session.working_directory = Some("/home/user/repo-a".to_string());
1566
1567 let data = make_status_data(Some("/home/user/repo-b"));
1568 let changed = session.update_from_status_line(&data);
1569
1570 assert!(changed, "should return true when cwd changes");
1571 assert_eq!(
1572 session.working_directory.as_deref(),
1573 Some("/home/user/repo-b"),
1574 "working_directory should be updated"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_update_from_status_line_cwd_same() {
1580 let mut session = SessionDomain::new(
1581 SessionId::new("test"),
1582 AgentType::GeneralPurpose,
1583 Model::Sonnet4,
1584 );
1585 session.working_directory = Some("/home/user/repo".to_string());
1586
1587 let data = make_status_data(Some("/home/user/repo"));
1588 let changed = session.update_from_status_line(&data);
1589
1590 assert!(!changed, "should return false when cwd is the same");
1591 }
1592
1593 #[test]
1594 fn test_update_from_status_line_cwd_none_to_some() {
1595 let mut session = SessionDomain::new(
1596 SessionId::new("test"),
1597 AgentType::GeneralPurpose,
1598 Model::Sonnet4,
1599 );
1600 let data = make_status_data(Some("/home/user/repo"));
1603 let changed = session.update_from_status_line(&data);
1604
1605 assert!(
1606 changed,
1607 "should return true when cwd goes from None to Some"
1608 );
1609 assert_eq!(
1610 session.working_directory.as_deref(),
1611 Some("/home/user/repo")
1612 );
1613 }
1614
1615 #[test]
1616 fn test_update_from_status_line_cwd_some_to_none() {
1617 let mut session = SessionDomain::new(
1618 SessionId::new("test"),
1619 AgentType::GeneralPurpose,
1620 Model::Sonnet4,
1621 );
1622 session.working_directory = Some("/home/user/repo".to_string());
1623
1624 let data = make_status_data(None);
1625 let changed = session.update_from_status_line(&data);
1626
1627 assert!(
1628 !changed,
1629 "should return false when incoming cwd is None (partial update)"
1630 );
1631 assert_eq!(
1632 session.working_directory.as_deref(),
1633 Some("/home/user/repo"),
1634 "should preserve existing cwd when incoming is None"
1635 );
1636 }
1637}