1use crate::lifecycle::{LifecycleEvent, NeedsInputReason, NotificationKind};
4use crate::{AgentType, ContextUsage, 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 #[serde(default)]
533 pub harness: crate::Harness,
534
535 pub model: Model,
537
538 #[serde(skip_serializing_if = "Option::is_none")]
542 pub model_display_override: Option<String>,
543
544 pub status: SessionStatus,
546
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub current_activity: Option<ActivityDetail>,
550
551 pub context: ContextUsage,
553
554 pub cost: Money,
556
557 pub duration: SessionDuration,
559
560 pub lines_changed: LinesChanged,
562
563 pub started_at: DateTime<Utc>,
565
566 pub last_activity: DateTime<Utc>,
568
569 #[serde(skip_serializing_if = "Option::is_none")]
571 pub working_directory: Option<String>,
572
573 #[serde(skip_serializing_if = "Option::is_none")]
575 pub claude_code_version: Option<String>,
576
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub tmux_pane: Option<String>,
580
581 #[serde(skip_serializing_if = "Option::is_none")]
584 pub project_root: Option<String>,
585
586 #[serde(skip_serializing_if = "Option::is_none")]
589 pub worktree_path: Option<String>,
590
591 #[serde(skip_serializing_if = "Option::is_none")]
593 pub worktree_branch: Option<String>,
594
595 #[serde(skip_serializing_if = "Option::is_none")]
597 pub parent_session_id: Option<SessionId>,
598
599 #[serde(default, skip_serializing_if = "Vec::is_empty")]
601 pub child_session_ids: Vec<SessionId>,
602
603 #[serde(skip_serializing_if = "Option::is_none")]
605 pub first_prompt: Option<String>,
606}
607
608impl SessionDomain {
609 pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
611 let now = Utc::now();
612 Self {
613 id,
614 agent_type,
615 harness: crate::Harness::default(),
616 model,
617 model_display_override: None,
618 status: SessionStatus::Idle,
619 current_activity: None,
620 context: ContextUsage::new(model.context_window_size()),
621 cost: Money::zero(),
622 duration: SessionDuration::default(),
623 lines_changed: LinesChanged::default(),
624 started_at: now,
625 last_activity: now,
626 working_directory: None,
627 claude_code_version: None,
628 tmux_pane: None,
629 project_root: None,
630 worktree_path: None,
631 worktree_branch: None,
632 parent_session_id: None,
633 child_session_ids: Vec::new(),
634 first_prompt: None,
635 }
636 }
637
638 pub fn from_status_line(data: &StatusLineData) -> Self {
640 use crate::model::derive_display_name;
641
642 let model = Model::from_id(&data.model_id);
643
644 let mut session = Self::new(
645 SessionId::new(&data.session_id),
646 AgentType::GeneralPurpose, model,
648 );
649 session.harness = crate::Harness::ClaudeCode;
651
652 if model.is_unknown() && !data.model_id.is_empty() {
655 session.model_display_override = Some(
656 data.model_display_name
657 .clone()
658 .unwrap_or_else(|| derive_display_name(&data.model_id)),
659 );
660 }
661
662 session.cost = Money::from_usd(data.cost_usd);
663 session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
664 session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
665 session.context = ContextUsage {
666 total_input_tokens: TokenCount::new(data.total_input_tokens),
667 total_output_tokens: TokenCount::new(data.total_output_tokens),
668 context_window_size: data.context_window_size,
669 current_input_tokens: TokenCount::new(data.current_input_tokens),
670 current_output_tokens: TokenCount::new(data.current_output_tokens),
671 cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
672 cache_read_tokens: TokenCount::new(data.cache_read_tokens),
673 };
674 session.working_directory = data.cwd.clone();
675 session.claude_code_version = data.version.clone();
676 session.last_activity = Utc::now();
677
678 session
679 }
680
681 pub fn update_from_status_line(&mut self, data: &StatusLineData) -> bool {
688 self.cost = Money::from_usd(data.cost_usd);
689 self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
690 self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
691 self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
692 self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
693 self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
694 self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
695 self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
696 self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
697 self.last_activity = Utc::now();
698
699 if self.status != SessionStatus::AttentionNeeded {
702 self.status = SessionStatus::Working;
703 }
704
705 let cwd_changed = match (&data.cwd, &self.working_directory) {
707 (Some(new_cwd), Some(old_cwd)) => new_cwd != old_cwd,
708 (Some(_), None) => true,
709 _ => false,
710 };
711 if cwd_changed {
712 self.working_directory = data.cwd.clone();
713 }
714 cwd_changed
715 }
716
717 pub fn apply_lifecycle_event(&mut self, event: &LifecycleEvent) {
722 self.last_activity = Utc::now();
723
724 match event {
725 LifecycleEvent::SessionStart { .. } => {
726 self.status = SessionStatus::Idle;
727 self.current_activity = None;
728 }
729 LifecycleEvent::SessionEnd { .. } => {
730 self.status = SessionStatus::Idle;
733 self.current_activity = None;
734 }
735 LifecycleEvent::WorkingStart => {
736 self.status = SessionStatus::Working;
737 self.current_activity = None;
738 }
739 LifecycleEvent::WorkingEnd | LifecycleEvent::Idle => {
740 self.status = SessionStatus::Idle;
741 self.current_activity = None;
742 }
743 LifecycleEvent::PromptSubmit { .. } => {
744 self.status = SessionStatus::Working;
745 self.current_activity = None;
746 }
748 LifecycleEvent::NeedsInput { reason } => {
749 self.status = SessionStatus::AttentionNeeded;
750 self.current_activity = Some(activity_for_needs_input(reason));
751 }
752 LifecycleEvent::ToolCallStart { name, .. } => {
753 self.status = SessionStatus::Working;
754 self.current_activity = Some(ActivityDetail::new(name.as_str()));
755 }
756 LifecycleEvent::ToolCallEnd { .. } => {
757 self.status = SessionStatus::Working;
758 self.current_activity = Some(ActivityDetail::thinking());
759 }
760 LifecycleEvent::ContextCompactStart { .. } => {
761 self.status = SessionStatus::Working;
762 self.current_activity = Some(ActivityDetail::with_context("Compacting"));
763 }
764 LifecycleEvent::ContextUpdate { tokens, cost_usd } => {
765 if let Some(c) = cost_usd {
772 self.cost = Money::from_usd(*c);
773 }
774 if let Some(t) = tokens {
775 let count = TokenCount::new(*t);
787 self.context.current_input_tokens = count;
788 self.context.total_input_tokens = count;
789 }
790 }
793 LifecycleEvent::ProviderModelChange { model, .. } => {
794 if let Some(id) = model {
803 let parsed = Model::from_id(id);
804 self.model = parsed;
805 self.model_display_override = if parsed.is_unknown() {
806 Some(crate::model::derive_display_name(id))
807 } else {
808 None
809 };
810 }
811 }
813 LifecycleEvent::Notification { kind, .. } => {
814 if matches!(kind, Some(NotificationKind::Setup)) {
815 self.status = SessionStatus::Working;
816 self.current_activity = Some(ActivityDetail::with_context("Setup"));
817 }
818 }
822 LifecycleEvent::ChildSessionStart { .. } | LifecycleEvent::ChildSessionEnd { .. } => {
823 self.status = SessionStatus::Working;
826 }
827 }
828 }
829
830 pub fn set_first_prompt_from_event(&mut self, event: &LifecycleEvent) {
832 if let LifecycleEvent::PromptSubmit { prompt: Some(text) } = event {
833 if !text.is_empty() {
834 self.set_first_prompt(text);
835 }
836 }
837 }
838
839 pub fn set_first_prompt(&mut self, prompt: &str) {
841 if self.first_prompt.is_none() && !prompt.is_empty() {
842 self.first_prompt = Some(prompt.to_string());
843 }
844 }
845
846 pub fn age(&self) -> chrono::Duration {
848 Utc::now().signed_duration_since(self.started_at)
849 }
850
851 pub fn time_since_activity(&self) -> chrono::Duration {
853 Utc::now().signed_duration_since(self.last_activity)
854 }
855
856 pub fn needs_context_attention(&self) -> bool {
858 self.context.is_warning() || self.context.is_critical()
859 }
860}
861
862impl Default for SessionDomain {
863 fn default() -> Self {
864 Self::new(
865 SessionId::new("unknown"),
866 AgentType::default(),
867 Model::default(),
868 )
869 }
870}
871
872#[derive(Debug, Clone)]
878pub struct ToolUsageRecord {
879 pub tool_name: String,
881 pub tool_use_id: Option<ToolUseId>,
883 pub timestamp: DateTime<Utc>,
885}
886
887#[derive(Debug, Clone)]
892pub struct SessionInfrastructure {
893 pub pid: Option<u32>,
895
896 pub process_start_time: Option<u64>,
899
900 pub socket_path: Option<PathBuf>,
902
903 pub transcript_path: Option<TranscriptPath>,
905
906 pub recent_tools: VecDeque<ToolUsageRecord>,
908
909 pub update_count: u64,
911
912 pub hook_event_count: u64,
914
915 pub last_error: Option<String>,
917}
918
919impl SessionInfrastructure {
920 const MAX_TOOL_HISTORY: usize = 50;
922
923 pub fn new() -> Self {
925 Self {
926 pid: None,
927 process_start_time: None,
928 socket_path: None,
929 transcript_path: None,
930 recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
931 update_count: 0,
932 hook_event_count: 0,
933 last_error: None,
934 }
935 }
936
937 pub fn set_pid(&mut self, pid: u32) {
950 if pid == 0 {
952 return;
953 }
954
955 if self.pid == Some(pid) {
957 return;
958 }
959
960 if let Some(start_time) = read_process_start_time(pid) {
963 self.pid = Some(pid);
964 self.process_start_time = Some(start_time);
965 } else {
966 debug!(
967 pid = pid,
968 "PID validation failed - process may have exited or is inaccessible"
969 );
970 }
971 }
972
973 pub fn is_process_alive(&self) -> bool {
983 let Some(pid) = self.pid else {
984 debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
986 return true;
987 };
988
989 let Some(expected_start_time) = self.process_start_time else {
990 let exists = procfs::process::Process::new(pid as i32).is_ok();
992 debug!(
993 pid,
994 exists, "is_process_alive: no start_time, checking procfs only"
995 );
996 return exists;
997 };
998
999 match read_process_start_time(pid) {
1001 Some(current_start_time) => {
1002 let alive = current_start_time == expected_start_time;
1003 if !alive {
1004 debug!(
1005 pid,
1006 expected_start_time,
1007 current_start_time,
1008 "is_process_alive: start time MISMATCH - PID reused?"
1009 );
1010 }
1011 alive
1012 }
1013 None => {
1014 debug!(
1015 pid,
1016 expected_start_time, "is_process_alive: process NOT FOUND in /proc"
1017 );
1018 false
1019 }
1020 }
1021 }
1022
1023 pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
1025 let record = ToolUsageRecord {
1026 tool_name: tool_name.to_string(),
1027 tool_use_id,
1028 timestamp: Utc::now(),
1029 };
1030
1031 self.recent_tools.push_back(record);
1032
1033 while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
1035 self.recent_tools.pop_front();
1036 }
1037
1038 self.hook_event_count += 1;
1039 }
1040
1041 pub fn record_update(&mut self) {
1043 self.update_count += 1;
1044 }
1045
1046 pub fn record_error(&mut self, error: &str) {
1048 self.last_error = Some(error.to_string());
1049 }
1050
1051 pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1053 self.recent_tools.back()
1054 }
1055
1056 pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1058 self.recent_tools.iter().rev()
1059 }
1060}
1061
1062fn activity_for_needs_input(reason: &NeedsInputReason) -> ActivityDetail {
1064 match reason {
1065 NeedsInputReason::InteractiveTool { tool } | NeedsInputReason::PermissionGate { tool } => {
1066 ActivityDetail::new(tool.as_str())
1067 }
1068 NeedsInputReason::Notification { kind, label } => {
1069 if let Some(text) = label.as_deref() {
1074 return ActivityDetail::with_context(text);
1075 }
1076 match kind {
1077 NotificationKind::PermissionPrompt => ActivityDetail::with_context("Permission"),
1078 NotificationKind::ElicitationDialog => ActivityDetail::with_context("MCP Input"),
1079 other => ActivityDetail::with_context(other.as_str()),
1080 }
1081 }
1082 }
1083}
1084
1085fn read_process_start_time(pid: u32) -> Option<u64> {
1092 let process = procfs::process::Process::new(pid as i32).ok()?;
1093 let stat = process.stat().ok()?;
1094 Some(stat.starttime)
1095}
1096
1097impl Default for SessionInfrastructure {
1098 fn default() -> Self {
1099 Self::new()
1100 }
1101}
1102
1103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1112pub struct SessionView {
1113 pub id: SessionId,
1115
1116 pub id_short: String,
1118
1119 pub agent_type: String,
1121
1122 #[serde(default)]
1125 pub harness: String,
1126
1127 pub model: String,
1129
1130 pub status: SessionStatus,
1132
1133 pub status_label: String,
1135
1136 pub activity_detail: Option<String>,
1138
1139 pub should_blink: bool,
1141
1142 pub status_icon: String,
1144
1145 pub context_percentage: f64,
1147
1148 pub context_display: String,
1150
1151 pub context_warning: bool,
1153
1154 pub context_critical: bool,
1156
1157 pub cost_display: String,
1159
1160 pub cost_usd: f64,
1162
1163 pub duration_display: String,
1165
1166 pub duration_seconds: f64,
1168
1169 pub lines_display: String,
1171
1172 pub working_directory: Option<String>,
1174
1175 pub needs_attention: bool,
1177
1178 pub last_activity_display: String,
1180
1181 pub age_display: String,
1183
1184 pub started_at: String,
1186
1187 pub last_activity: String,
1189
1190 pub tmux_pane: Option<String>,
1192
1193 #[serde(skip_serializing_if = "Option::is_none")]
1195 pub project_root: Option<String>,
1196
1197 #[serde(skip_serializing_if = "Option::is_none")]
1199 pub worktree_path: Option<String>,
1200
1201 #[serde(skip_serializing_if = "Option::is_none")]
1203 pub worktree_branch: Option<String>,
1204
1205 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub parent_session_id: Option<SessionId>,
1208
1209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1211 pub child_session_ids: Vec<SessionId>,
1212
1213 #[serde(skip_serializing_if = "Option::is_none")]
1215 pub first_prompt: Option<String>,
1216}
1217
1218impl SessionView {
1219 pub fn from_domain(session: &SessionDomain) -> Self {
1221 let now = Utc::now();
1222 let since_activity = now.signed_duration_since(session.last_activity);
1223 let age = now.signed_duration_since(session.started_at);
1224
1225 Self {
1226 id: session.id.clone(),
1227 id_short: session.id.short().to_string(),
1228 agent_type: session.agent_type.short_name().to_string(),
1229 harness: session.harness.short_tag().to_string(),
1230 model: if session.model.is_unknown() {
1231 session
1232 .model_display_override
1233 .clone()
1234 .unwrap_or_else(|| session.model.display_name().to_string())
1235 } else {
1236 session.model.display_name().to_string()
1237 },
1238 status: session.status,
1239 status_label: session.status.label().to_string(),
1240 activity_detail: session
1241 .current_activity
1242 .as_ref()
1243 .map(|a| a.display().into_owned()),
1244 should_blink: session.status.should_blink(),
1245 status_icon: session.status.icon().to_string(),
1246 context_percentage: session.context.usage_percentage(),
1247 context_display: session.context.format(),
1248 context_warning: session.context.is_warning(),
1249 context_critical: session.context.is_critical(),
1250 cost_display: session.cost.format(),
1251 cost_usd: session.cost.as_usd(),
1252 duration_display: session.duration.format(),
1253 duration_seconds: session.duration.total_seconds(),
1254 lines_display: session.lines_changed.format(),
1255 working_directory: session.working_directory.clone().map(|p| {
1256 if p.len() > 30 {
1258 format!("...{}", &p[p.len().saturating_sub(27)..])
1259 } else {
1260 p
1261 }
1262 }),
1263 needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1264 last_activity_display: format_duration(since_activity),
1265 age_display: format_duration(age),
1266 started_at: session.started_at.to_rfc3339(),
1267 last_activity: session.last_activity.to_rfc3339(),
1268 tmux_pane: session.tmux_pane.clone(),
1269 project_root: session.project_root.clone(),
1270 worktree_path: session.worktree_path.clone(),
1271 worktree_branch: session.worktree_branch.clone(),
1272 parent_session_id: session.parent_session_id.clone(),
1273 child_session_ids: session.child_session_ids.clone(),
1274 first_prompt: session.first_prompt.clone(),
1275 }
1276 }
1277}
1278
1279impl From<&SessionDomain> for SessionView {
1280 fn from(session: &SessionDomain) -> Self {
1281 Self::from_domain(session)
1282 }
1283}
1284
1285fn format_duration(duration: chrono::Duration) -> String {
1287 let secs = duration.num_seconds();
1288 if secs < 0 {
1289 return "now".to_string();
1290 }
1291 if secs < 60 {
1292 format!("{secs}s ago")
1293 } else if secs < 3600 {
1294 let mins = secs / 60;
1295 format!("{mins}m ago")
1296 } else if secs < 86400 {
1297 let hours = secs / 3600;
1298 format!("{hours}h ago")
1299 } else {
1300 let days = secs / 86400;
1301 format!("{days}d ago")
1302 }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307 use super::*;
1308 use crate::tool::Tool;
1309
1310 fn create_test_session(id: &str) -> SessionDomain {
1312 SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1313 }
1314
1315 #[test]
1316 fn test_session_id_short() {
1317 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1318 assert_eq!(id.short(), "8e11bfb5");
1319 }
1320
1321 #[test]
1322 fn test_session_id_short_short_id() {
1323 let id = SessionId::new("abc");
1324 assert_eq!(id.short(), "abc");
1325 }
1326
1327 #[test]
1328 fn test_session_status_display() {
1329 let status = SessionStatus::Working;
1330 assert_eq!(format!("{status}"), "Working");
1331 }
1332
1333 #[test]
1334 fn test_session_domain_creation() {
1335 let session = SessionDomain::new(
1336 SessionId::new("test-123"),
1337 AgentType::GeneralPurpose,
1338 Model::Opus45,
1339 );
1340 assert_eq!(session.id.as_str(), "test-123");
1341 assert_eq!(session.model, Model::Opus45);
1342 assert!(session.cost.is_zero());
1343 }
1344
1345 #[test]
1346 fn test_session_view_from_domain() {
1347 let session = SessionDomain::new(
1348 SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1349 AgentType::Explore,
1350 Model::Sonnet4,
1351 );
1352 let view = SessionView::from_domain(&session);
1353
1354 assert_eq!(view.id_short, "8e11bfb5");
1355 assert_eq!(view.agent_type, "explore");
1356 assert_eq!(view.model, "Sonnet 4");
1357 }
1358
1359 #[test]
1360 fn test_session_view_unknown_model_with_override() {
1361 let mut session = SessionDomain::new(
1362 SessionId::new("test-override"),
1363 AgentType::GeneralPurpose,
1364 Model::Unknown,
1365 );
1366 session.model_display_override = Some("GPT-4o".to_string());
1367
1368 let view = SessionView::from_domain(&session);
1369 assert_eq!(view.model, "GPT-4o");
1370 }
1371
1372 #[test]
1373 fn test_session_view_unknown_model_without_override() {
1374 let session = SessionDomain::new(
1375 SessionId::new("test-no-override"),
1376 AgentType::GeneralPurpose,
1377 Model::Unknown,
1378 );
1379
1380 let view = SessionView::from_domain(&session);
1381 assert_eq!(view.model, "Unknown");
1382 }
1383
1384 #[test]
1385 fn test_session_view_known_model_ignores_override() {
1386 let mut session = SessionDomain::new(
1387 SessionId::new("test-known"),
1388 AgentType::GeneralPurpose,
1389 Model::Opus46,
1390 );
1391 session.model_display_override = Some("something else".to_string());
1393
1394 let view = SessionView::from_domain(&session);
1395 assert_eq!(view.model, "Opus 4.6");
1396 }
1397
1398 #[test]
1399 fn test_lines_changed() {
1400 let lines = LinesChanged::new(150, 30);
1401 assert_eq!(lines.net(), 120);
1402 assert_eq!(lines.churn(), 180);
1403 assert_eq!(lines.format(), "+150 -30");
1404 assert_eq!(lines.format_net(), "+120");
1405 }
1406
1407 #[test]
1408 fn test_session_duration_formatting() {
1409 assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1410 assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1411 assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1412 }
1413
1414 #[test]
1415 fn test_session_id_pending_from_pid() {
1416 let id = SessionId::pending_from_pid(12345);
1417 assert_eq!(id.as_str(), "pending-12345");
1418 assert!(id.is_pending());
1419 assert_eq!(id.pending_pid(), Some(12345));
1420 }
1421
1422 #[test]
1423 fn test_session_id_is_pending_true() {
1424 let id = SessionId::new("pending-99999");
1425 assert!(id.is_pending());
1426 }
1427
1428 #[test]
1429 fn test_session_id_is_pending_false() {
1430 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1431 assert!(!id.is_pending());
1432 }
1433
1434 #[test]
1435 fn test_session_id_pending_pid_returns_none_for_regular_id() {
1436 let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1437 assert_eq!(id.pending_pid(), None);
1438 }
1439
1440 #[test]
1441 fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1442 let id = SessionId::new("pending-not-a-number");
1443 assert_eq!(id.pending_pid(), None);
1444 }
1445
1446 #[test]
1447 fn lifecycle_provider_model_change_known_claude_id() {
1448 let mut session = create_test_session("test-pmc-known");
1451 session.model = Model::Unknown;
1452 session.model_display_override = Some("stale".to_string());
1453
1454 session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1455 provider: Some("anthropic".to_string()),
1456 model: Some("claude-sonnet-4-5-20250929".to_string()),
1457 });
1458
1459 assert_eq!(session.model, Model::Sonnet45);
1460 assert!(
1461 session.model_display_override.is_none(),
1462 "override must be cleared when the id maps to a known model"
1463 );
1464 }
1465
1466 #[test]
1467 fn lifecycle_provider_model_change_unknown_id() {
1468 let mut session = create_test_session("test-pmc-unknown");
1472 session.model = Model::Unknown;
1473 session.model_display_override = None;
1474
1475 session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1476 provider: Some("openai".to_string()),
1477 model: Some("gpt-4o".to_string()),
1478 });
1479
1480 assert_eq!(session.model, Model::Unknown);
1481 assert_eq!(session.model_display_override.as_deref(), Some("gpt-4o"));
1482 }
1483
1484 #[test]
1485 fn lifecycle_provider_model_change_no_model_is_noop() {
1486 let mut session = create_test_session("test-pmc-none");
1487 session.model = Model::Sonnet4;
1488 session.model_display_override = None;
1489
1490 session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1491 provider: Some("anthropic".to_string()),
1492 model: None,
1493 });
1494
1495 assert_eq!(session.model, Model::Sonnet4);
1496 assert!(session.model_display_override.is_none());
1497 }
1498
1499 #[test]
1500 fn lifecycle_needs_input_for_interactive_tool() {
1501 let mut session = create_test_session("test-interactive");
1502
1503 session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1504 reason: NeedsInputReason::InteractiveTool {
1505 tool: Tool::AskUserQuestion,
1506 },
1507 });
1508 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1509 assert_eq!(
1510 session
1511 .current_activity
1512 .as_ref()
1513 .map(|a| a.display())
1514 .as_deref(),
1515 Some("AskUserQuestion")
1516 );
1517
1518 session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
1519 name: Tool::AskUserQuestion,
1520 tool_use_id: None,
1521 is_error: false,
1522 });
1523 assert_eq!(session.status, SessionStatus::Working);
1524 }
1525
1526 #[test]
1527 fn lifecycle_needs_input_for_enter_plan_mode() {
1528 let mut session = create_test_session("test-plan");
1529
1530 session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1531 reason: NeedsInputReason::InteractiveTool {
1532 tool: Tool::EnterPlanMode,
1533 },
1534 });
1535 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1536 assert_eq!(
1537 session
1538 .current_activity
1539 .as_ref()
1540 .map(|a| a.display())
1541 .as_deref(),
1542 Some("EnterPlanMode")
1543 );
1544 }
1545
1546 #[test]
1547 fn lifecycle_needs_input_notification_uses_label_when_present() {
1548 let mut session = create_test_session("test-label");
1552
1553 session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1554 reason: NeedsInputReason::Notification {
1555 kind: NotificationKind::PermissionPrompt,
1556 label: Some("Allow `rm -rf /tmp/cache`?".into()),
1557 },
1558 });
1559 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1560 assert_eq!(
1561 session
1562 .current_activity
1563 .as_ref()
1564 .map(|a| a.display())
1565 .as_deref(),
1566 Some("Allow `rm -rf /tmp/cache`?")
1567 );
1568 }
1569
1570 #[test]
1571 fn lifecycle_needs_input_notification_falls_back_to_kind_when_label_absent() {
1572 let mut session = create_test_session("test-no-label");
1576
1577 session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1578 reason: NeedsInputReason::Notification {
1579 kind: NotificationKind::PermissionPrompt,
1580 label: None,
1581 },
1582 });
1583 assert_eq!(session.status, SessionStatus::AttentionNeeded);
1584 assert_eq!(
1585 session
1586 .current_activity
1587 .as_ref()
1588 .map(|a| a.display())
1589 .as_deref(),
1590 Some("Permission")
1591 );
1592
1593 session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1594 reason: NeedsInputReason::Notification {
1595 kind: NotificationKind::ElicitationDialog,
1596 label: None,
1597 },
1598 });
1599 assert_eq!(
1600 session
1601 .current_activity
1602 .as_ref()
1603 .map(|a| a.display())
1604 .as_deref(),
1605 Some("MCP Input")
1606 );
1607 }
1608
1609 #[test]
1610 fn lifecycle_tool_call_start_for_standard_tool() {
1611 let mut session = create_test_session("test-standard");
1612
1613 session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
1614 name: Tool::Bash,
1615 tool_use_id: None,
1616 input: None,
1617 });
1618 assert_eq!(session.status, SessionStatus::Working);
1619 assert_eq!(
1620 session
1621 .current_activity
1622 .as_ref()
1623 .map(|a| a.display())
1624 .as_deref(),
1625 Some("Bash")
1626 );
1627
1628 session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
1629 name: Tool::Bash,
1630 tool_use_id: None,
1631 is_error: false,
1632 });
1633 assert_eq!(session.status, SessionStatus::Working);
1634 }
1635
1636 #[test]
1637 fn lifecycle_unknown_tool_lands_in_other_and_keeps_name() {
1638 let mut session = create_test_session("test-other");
1642
1643 session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
1644 name: Tool::Other("custom_pi_tool".into()),
1645 tool_use_id: None,
1646 input: None,
1647 });
1648 assert_eq!(session.status, SessionStatus::Working);
1649 assert_eq!(
1650 session
1651 .current_activity
1652 .as_ref()
1653 .map(|a| a.display())
1654 .as_deref(),
1655 Some("custom_pi_tool")
1656 );
1657 }
1658
1659 #[test]
1660 fn test_activity_detail_creation() {
1661 let detail = ActivityDetail::new("Bash");
1662 assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1663 assert!(detail.started_at <= Utc::now());
1664 assert!(detail.context.is_none());
1665 }
1666
1667 #[test]
1668 fn test_activity_detail_with_context() {
1669 let detail = ActivityDetail::with_context("Compacting");
1670 assert!(detail.tool_name.is_none());
1671 assert_eq!(detail.context.as_deref(), Some("Compacting"));
1672 }
1673
1674 #[test]
1675 fn test_activity_detail_display() {
1676 let detail = ActivityDetail::new("Read");
1677 assert_eq!(detail.display(), "Read");
1678
1679 let context_detail = ActivityDetail::with_context("Setup");
1680 assert_eq!(context_detail.display(), "Setup");
1681 }
1682
1683 #[test]
1684 fn test_new_session_status_variants() {
1685 let idle = SessionStatus::Idle;
1687 let working = SessionStatus::Working;
1688 let attention = SessionStatus::AttentionNeeded;
1689
1690 assert_eq!(idle.label(), "idle");
1691 assert_eq!(working.label(), "working");
1692 assert_eq!(attention.label(), "needs input");
1693 }
1694
1695 #[test]
1696 fn test_session_status_should_blink() {
1697 assert!(!SessionStatus::Idle.should_blink());
1698 assert!(!SessionStatus::Working.should_blink());
1699 assert!(SessionStatus::AttentionNeeded.should_blink());
1700 }
1701
1702 #[test]
1703 fn test_session_status_icons() {
1704 assert_eq!(SessionStatus::Idle.icon(), "-");
1705 assert_eq!(SessionStatus::Working.icon(), ">");
1706 assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1707 }
1708
1709 #[test]
1710 fn test_session_domain_new_fields_default() {
1711 let session = create_test_session("test-defaults");
1712 assert!(session.project_root.is_none());
1713 assert!(session.worktree_path.is_none());
1714 assert!(session.worktree_branch.is_none());
1715 assert!(session.parent_session_id.is_none());
1716 assert!(session.child_session_ids.is_empty());
1717 }
1718
1719 #[test]
1720 fn test_session_view_includes_new_fields() {
1721 let mut session = create_test_session("test-view-fields");
1722 session.project_root = Some("/home/user/project".to_string());
1723 session.worktree_path = Some("/home/user/worktree".to_string());
1724 session.worktree_branch = Some("feature-x".to_string());
1725 session.parent_session_id = Some(SessionId::new("parent-123"));
1726 session.child_session_ids = vec![SessionId::new("child-1"), SessionId::new("child-2")];
1727
1728 let view = SessionView::from_domain(&session);
1729
1730 assert_eq!(view.project_root, Some("/home/user/project".to_string()));
1731 assert_eq!(view.worktree_path, Some("/home/user/worktree".to_string()));
1732 assert_eq!(view.worktree_branch, Some("feature-x".to_string()));
1733 assert_eq!(view.parent_session_id, Some(SessionId::new("parent-123")));
1734 assert_eq!(view.child_session_ids.len(), 2);
1735 assert_eq!(view.child_session_ids[0].as_str(), "child-1");
1736 assert_eq!(view.child_session_ids[1].as_str(), "child-2");
1737 }
1738
1739 fn make_status_data(cwd: Option<&str>) -> StatusLineData {
1744 StatusLineData {
1745 session_id: "test".to_string(),
1746 model_id: "claude-sonnet-4-20250514".to_string(),
1747 model_display_name: None,
1748 cost_usd: 0.10,
1749 total_duration_ms: 1000,
1750 api_duration_ms: 500,
1751 lines_added: 10,
1752 lines_removed: 5,
1753 total_input_tokens: 1000,
1754 total_output_tokens: 500,
1755 context_window_size: 200_000,
1756 current_input_tokens: 800,
1757 current_output_tokens: 400,
1758 cache_creation_tokens: 0,
1759 cache_read_tokens: 0,
1760 cwd: cwd.map(|s| s.to_string()),
1761 version: None,
1762 }
1763 }
1764
1765 #[test]
1766 fn test_update_from_status_line_cwd_changed() {
1767 let mut session = SessionDomain::new(
1768 SessionId::new("test"),
1769 AgentType::GeneralPurpose,
1770 Model::Sonnet4,
1771 );
1772 session.working_directory = Some("/home/user/repo-a".to_string());
1773
1774 let data = make_status_data(Some("/home/user/repo-b"));
1775 let changed = session.update_from_status_line(&data);
1776
1777 assert!(changed, "should return true when cwd changes");
1778 assert_eq!(
1779 session.working_directory.as_deref(),
1780 Some("/home/user/repo-b"),
1781 "working_directory should be updated"
1782 );
1783 }
1784
1785 #[test]
1786 fn test_update_from_status_line_cwd_same() {
1787 let mut session = SessionDomain::new(
1788 SessionId::new("test"),
1789 AgentType::GeneralPurpose,
1790 Model::Sonnet4,
1791 );
1792 session.working_directory = Some("/home/user/repo".to_string());
1793
1794 let data = make_status_data(Some("/home/user/repo"));
1795 let changed = session.update_from_status_line(&data);
1796
1797 assert!(!changed, "should return false when cwd is the same");
1798 }
1799
1800 #[test]
1801 fn test_update_from_status_line_cwd_none_to_some() {
1802 let mut session = SessionDomain::new(
1803 SessionId::new("test"),
1804 AgentType::GeneralPurpose,
1805 Model::Sonnet4,
1806 );
1807 let data = make_status_data(Some("/home/user/repo"));
1810 let changed = session.update_from_status_line(&data);
1811
1812 assert!(
1813 changed,
1814 "should return true when cwd goes from None to Some"
1815 );
1816 assert_eq!(
1817 session.working_directory.as_deref(),
1818 Some("/home/user/repo")
1819 );
1820 }
1821
1822 #[test]
1823 fn test_update_from_status_line_cwd_some_to_none() {
1824 let mut session = SessionDomain::new(
1825 SessionId::new("test"),
1826 AgentType::GeneralPurpose,
1827 Model::Sonnet4,
1828 );
1829 session.working_directory = Some("/home/user/repo".to_string());
1830
1831 let data = make_status_data(None);
1832 let changed = session.update_from_status_line(&data);
1833
1834 assert!(
1835 !changed,
1836 "should return false when incoming cwd is None (partial update)"
1837 );
1838 assert_eq!(
1839 session.working_directory.as_deref(),
1840 Some("/home/user/repo"),
1841 "should preserve existing cwd when incoming is None"
1842 );
1843 }
1844}