Skip to main content

atm_core/
session.rs

1//! Session domain entities and value objects.
2
3use 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// ============================================================================
14// Type-Safe Identifiers
15// ============================================================================
16
17/// Unique identifier for a Claude Code session.
18///
19/// Wraps a UUID string (e.g., "8e11bfb5-7dc2-432b-9206-928fa5c35731").
20/// Obtained from Claude Code's status line JSON `session_id` field.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct SessionId(String);
24
25/// Prefix used for pending session IDs (sessions discovered before their real ID is known).
26pub const PENDING_SESSION_PREFIX: &str = "pending-";
27
28impl SessionId {
29    /// Creates a new SessionId from a string.
30    ///
31    /// Note: This does not validate UUID format. Claude Code provides
32    /// the session_id, so we trust its format.
33    pub fn new(id: impl Into<String>) -> Self {
34        Self(id.into())
35    }
36
37    /// Creates a pending session ID from a process ID.
38    ///
39    /// Used when a Claude process is discovered but no transcript exists yet
40    /// (e.g., session just started, no conversation has occurred).
41    /// The pending session will be upgraded to the real session ID when
42    /// it arrives via hook event or status line.
43    pub fn pending_from_pid(pid: u32) -> Self {
44        Self(format!("{PENDING_SESSION_PREFIX}{pid}"))
45    }
46
47    /// Checks if this is a pending session ID (not yet associated with real session).
48    #[must_use]
49    pub fn is_pending(&self) -> bool {
50        self.0.starts_with(PENDING_SESSION_PREFIX)
51    }
52
53    /// Extracts the PID from a pending session ID.
54    ///
55    /// Returns `None` if this is not a pending session ID or the PID cannot be parsed.
56    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    /// Returns the underlying string reference.
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69
70    /// Returns a shortened display form (first 8 characters).
71    ///
72    /// Useful for compact TUI display.
73    #[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/// Unique identifier for a tool invocation.
104///
105/// Format: "toolu_..." (e.g., "toolu_01ABC123XYZ")
106/// Provided by Claude Code in hook events.
107#[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/// Path to a session's transcript JSONL file.
134///
135/// Example: "/home/user/.claude/projects/.../session.jsonl"
136#[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    /// Returns the filename portion of the path.
150    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// ============================================================================
168// Session Status (3-State Model)
169// ============================================================================
170
171/// Current operational status of a session.
172///
173/// Three fundamental states based on user action requirements:
174/// - **Idle**: Nothing happening - Claude finished, waiting for user
175/// - **Working**: Claude is actively processing - user just waits
176/// - **AttentionNeeded**: User must act for session to proceed
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum SessionStatus {
180    /// Session is idle - Claude finished, waiting for user's next action.
181    /// User can take their time, no urgency.
182    #[default]
183    Idle,
184
185    /// Claude is actively processing - user just waits.
186    /// Work is happening, no user action needed.
187    Working,
188
189    /// User must take action for the session to proceed.
190    /// Something is blocked waiting for user input.
191    AttentionNeeded,
192}
193
194impl SessionStatus {
195    /// Returns the display label for this status.
196    #[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    /// Returns the ASCII icon for this status.
206    #[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    /// Returns true if this status should blink in the UI.
216    #[must_use]
217    pub fn should_blink(&self) -> bool {
218        matches!(self, Self::AttentionNeeded)
219    }
220
221    /// Returns true if the session is actively processing.
222    #[must_use]
223    pub fn is_active(&self) -> bool {
224        matches!(self, Self::Working)
225    }
226
227    /// Returns true if user action is needed.
228    #[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// ============================================================================
245// Activity Detail
246// ============================================================================
247
248/// Detailed information about current session activity.
249///
250/// Provides structured details alongside the simple SessionStatus enum.
251/// This separates "what state are we in" from "what specifically is happening".
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct ActivityDetail {
254    /// Tool name if running/waiting on a tool
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub tool_name: Option<String>,
257    /// When the current activity started
258    pub started_at: DateTime<Utc>,
259    /// Additional context (e.g., "Compacting", "Setup", "Thinking")
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub context: Option<String>,
262}
263
264impl ActivityDetail {
265    /// Creates a new ActivityDetail for a tool operation.
266    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    /// Creates an ActivityDetail with context but no specific tool.
275    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    /// Creates an ActivityDetail for "thinking" state.
284    pub fn thinking() -> Self {
285        Self::with_context("Thinking")
286    }
287
288    /// Returns how long this activity has been running.
289    pub fn duration(&self) -> chrono::Duration {
290        Utc::now().signed_duration_since(self.started_at)
291    }
292
293    /// Returns a display string for this activity.
294    ///
295    /// Returns a `Cow<str>` for zero-copy access when possible.
296    #[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// ============================================================================
315// Value Objects
316// ============================================================================
317
318/// Duration tracking for a session.
319///
320/// Based on Claude Code status line `cost.total_duration_ms`.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
322pub struct SessionDuration {
323    /// Total duration in milliseconds
324    total_ms: u64,
325    /// API call duration in milliseconds (time spent waiting for Claude)
326    api_ms: u64,
327}
328
329impl SessionDuration {
330    /// Creates a new SessionDuration.
331    pub fn new(total_ms: u64, api_ms: u64) -> Self {
332        Self { total_ms, api_ms }
333    }
334
335    /// Creates from total duration only.
336    pub fn from_total_ms(total_ms: u64) -> Self {
337        Self {
338            total_ms,
339            api_ms: 0,
340        }
341    }
342
343    /// Returns total duration in milliseconds.
344    pub fn total_ms(&self) -> u64 {
345        self.total_ms
346    }
347
348    /// Returns API duration in milliseconds.
349    pub fn api_ms(&self) -> u64 {
350        self.api_ms
351    }
352
353    /// Returns total duration as seconds (float).
354    pub fn total_seconds(&self) -> f64 {
355        self.total_ms as f64 / 1000.0
356    }
357
358    /// Returns the overhead time (total - API).
359    pub fn overhead_ms(&self) -> u64 {
360        self.total_ms.saturating_sub(self.api_ms)
361    }
362
363    /// Formats duration for display.
364    ///
365    /// Returns format like "35s", "2m 15s", "1h 30m"
366    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    /// Formats duration compactly.
390    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/// Tracks lines added and removed in a session.
411///
412/// Based on Claude Code status line `cost.total_lines_added/removed`.
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
414pub struct LinesChanged {
415    /// Lines added
416    pub added: u64,
417    /// Lines removed
418    pub removed: u64,
419}
420
421impl LinesChanged {
422    /// Creates new LinesChanged.
423    pub fn new(added: u64, removed: u64) -> Self {
424        Self { added, removed }
425    }
426
427    /// Returns net change (added - removed).
428    pub fn net(&self) -> i64 {
429        self.added as i64 - self.removed as i64
430    }
431
432    /// Returns total churn (added + removed).
433    pub fn churn(&self) -> u64 {
434        self.added.saturating_add(self.removed)
435    }
436
437    /// Returns true if no changes have been made.
438    pub fn is_empty(&self) -> bool {
439        self.added == 0 && self.removed == 0
440    }
441
442    /// Formats for display (e.g., "+150 -30").
443    pub fn format(&self) -> String {
444        format!("+{} -{}", self.added, self.removed)
445    }
446
447    /// Formats net change with sign.
448    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// ============================================================================
465// Status Line Data Transfer Object
466// ============================================================================
467
468/// Data extracted from Claude Code's status line JSON.
469///
470/// This struct consolidates the many parameters previously passed to
471/// `SessionDomain::from_status_line()` and `update_from_status_line()`,
472/// providing named fields for clarity and reducing error-prone parameter ordering.
473#[derive(Debug, Clone, Default)]
474pub struct StatusLineData {
475    /// Session ID from Claude Code
476    pub session_id: String,
477    /// Model ID (e.g., "claude-sonnet-4-20250514")
478    pub model_id: String,
479    /// Display name from the provider (e.g., "Claude Opus 4.6"), if provided
480    pub model_display_name: Option<String>,
481    /// Total cost in USD
482    pub cost_usd: f64,
483    /// Total session duration in milliseconds
484    pub total_duration_ms: u64,
485    /// Time spent waiting for API responses in milliseconds
486    pub api_duration_ms: u64,
487    /// Lines of code added
488    pub lines_added: u64,
489    /// Lines of code removed
490    pub lines_removed: u64,
491    /// Total input tokens across all requests
492    pub total_input_tokens: u64,
493    /// Total output tokens across all responses
494    pub total_output_tokens: u64,
495    /// Context window size for the model
496    pub context_window_size: u32,
497    /// Input tokens in current context
498    pub current_input_tokens: u64,
499    /// Output tokens in current context
500    pub current_output_tokens: u64,
501    /// Tokens used for cache creation
502    pub cache_creation_tokens: u64,
503    /// Tokens read from cache
504    pub cache_read_tokens: u64,
505    /// Current working directory
506    pub cwd: Option<String>,
507    /// Claude Code version
508    pub version: Option<String>,
509}
510
511// ============================================================================
512// Domain Entity
513// ============================================================================
514
515/// Core domain model for a Claude Code session.
516///
517/// Contains pure business logic and state. Does NOT include
518/// infrastructure concerns (PIDs, sockets, file paths).
519///
520/// Consistent with CONCURRENCY_MODEL.md RegistryActor ownership.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522pub struct SessionDomain {
523    /// Unique session identifier
524    pub id: SessionId,
525
526    /// Type of agent (main, subagent, etc.)
527    pub agent_type: AgentType,
528
529    /// Claude model being used
530    pub model: Model,
531
532    /// Display name override for unknown/non-Anthropic models.
533    /// When `model` is `Unknown`, this holds the raw model ID or
534    /// the provider-supplied display name for UI rendering.
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub model_display_override: Option<String>,
537
538    /// Current session status (3-state model)
539    pub status: SessionStatus,
540
541    /// Current activity details (tool name, context, timing)
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub current_activity: Option<ActivityDetail>,
544
545    /// Context window usage
546    pub context: ContextUsage,
547
548    /// Accumulated cost
549    pub cost: Money,
550
551    /// Session duration tracking
552    pub duration: SessionDuration,
553
554    /// Lines of code changed
555    pub lines_changed: LinesChanged,
556
557    /// When the session started
558    pub started_at: DateTime<Utc>,
559
560    /// Last activity timestamp
561    pub last_activity: DateTime<Utc>,
562
563    /// Working directory (project root)
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub working_directory: Option<String>,
566
567    /// Claude Code version
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub claude_code_version: Option<String>,
570
571    /// Tmux pane ID (e.g., "%5") if session is running in tmux
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub tmux_pane: Option<String>,
574
575    /// Git project root (resolved from working_directory).
576    /// Shared across all worktrees of the same repo.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub project_root: Option<String>,
579
580    /// Git worktree path (specific checkout directory).
581    /// For the main checkout, this equals project_root.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub worktree_path: Option<String>,
584
585    /// Git branch name for this worktree.
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub worktree_branch: Option<String>,
588
589    /// Parent session ID (set when this session is a subagent).
590    #[serde(skip_serializing_if = "Option::is_none")]
591    pub parent_session_id: Option<SessionId>,
592
593    /// Child subagent session IDs spawned by this session.
594    #[serde(default, skip_serializing_if = "Vec::is_empty")]
595    pub child_session_ids: Vec<SessionId>,
596
597    /// First user prompt (captured from the first UserPromptSubmit hook event).
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub first_prompt: Option<String>,
600}
601
602impl SessionDomain {
603    /// Creates a new SessionDomain with required fields.
604    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    /// Creates a SessionDomain from Claude Code status line data.
632    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, // Default, may be updated by hook events
640            model,
641        );
642
643        // For unknown models, store a display name fallback:
644        // prefer provider-supplied display_name, then derive from raw ID
645        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    /// Updates the session with new status line data.
673    ///
674    /// When `current_usage` is null in Claude's status line, all current_* values
675    /// will be 0, which correctly resets context percentage to 0%.
676    ///
677    /// Returns `true` if the working directory changed (caller should re-resolve git info).
678    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        // Status line update means Claude is working
691        // Don't override AttentionNeeded (permission wait)
692        if self.status != SessionStatus::AttentionNeeded {
693            self.status = SessionStatus::Working;
694        }
695
696        // Detect working directory change
697        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    /// Updates status based on a hook event.
709    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                // first_prompt is set separately via set_first_prompt()
732            }
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                // Session will be removed by registry
743                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                // Notification handling is done separately with notification_type
756                // This is a fallback - don't change status
757            }
758            HookEventType::SubagentStart | HookEventType::SubagentStop => {
759                // Subagent tracking deferred to future PR
760                self.status = SessionStatus::Working;
761            }
762        }
763    }
764
765    /// Stores the first user prompt if not already set.
766    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    /// Updates status based on a notification event.
773    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                // Informational notification - no status change
791            }
792        }
793    }
794
795    /// Returns the session age (time since started).
796    pub fn age(&self) -> chrono::Duration {
797        Utc::now().signed_duration_since(self.started_at)
798    }
799
800    /// Returns time since last activity.
801    pub fn time_since_activity(&self) -> chrono::Duration {
802        Utc::now().signed_duration_since(self.last_activity)
803    }
804
805    /// Returns true if context usage needs attention.
806    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// ============================================================================
822// Infrastructure Entity
823// ============================================================================
824
825/// Record of a tool invocation.
826#[derive(Debug, Clone)]
827pub struct ToolUsageRecord {
828    /// Name of the tool (e.g., "Bash", "Read", "Write")
829    pub tool_name: String,
830    /// Unique ID for this tool invocation
831    pub tool_use_id: Option<ToolUseId>,
832    /// When the tool was invoked
833    pub timestamp: DateTime<Utc>,
834}
835
836/// Infrastructure-level data for a session.
837///
838/// Contains OS/system concerns that don't belong in the domain model.
839/// Owned by RegistryActor alongside SessionDomain.
840#[derive(Debug, Clone)]
841pub struct SessionInfrastructure {
842    /// Process ID of the Claude Code process (if known)
843    pub pid: Option<u32>,
844
845    /// Process start time in clock ticks (from /proc/{pid}/stat field 22).
846    /// Used to detect PID reuse - if the start time changes, it's a different process.
847    pub process_start_time: Option<u64>,
848
849    /// Path to the Unix socket for this session (if applicable)
850    pub socket_path: Option<PathBuf>,
851
852    /// Path to the transcript JSONL file
853    pub transcript_path: Option<TranscriptPath>,
854
855    /// Recent tool usage history (bounded FIFO queue)
856    pub recent_tools: VecDeque<ToolUsageRecord>,
857
858    /// Number of status updates received
859    pub update_count: u64,
860
861    /// Number of hook events received
862    pub hook_event_count: u64,
863
864    /// Last error encountered (for debugging)
865    pub last_error: Option<String>,
866}
867
868impl SessionInfrastructure {
869    /// Maximum number of tool records to keep.
870    const MAX_TOOL_HISTORY: usize = 50;
871
872    /// Creates new SessionInfrastructure.
873    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    /// Sets the process ID and captures the process start time for PID reuse detection.
887    ///
888    /// The start time is read from `/proc/{pid}/stat` field 22 (starttime in clock ticks).
889    /// If the PID is already set with the same value, this is a no-op.
890    ///
891    /// # Validation
892    ///
893    /// The PID is only stored if:
894    /// - It's non-zero (PID 0 is invalid)
895    /// - We can successfully read its start time from `/proc/{pid}/stat`
896    ///
897    /// This prevents storing invalid PIDs that would cause incorrect liveness checks.
898    pub fn set_pid(&mut self, pid: u32) {
899        // PID 0 is invalid
900        if pid == 0 {
901            return;
902        }
903
904        // Only update if PID changed or wasn't set
905        if self.pid == Some(pid) {
906            return;
907        }
908
909        // Only store PID if we can read and validate its start time
910        // This ensures the PID is valid and gives us PID reuse protection
911        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    /// Checks if the tracked process is still alive.
923    ///
924    /// Returns `true` if:
925    /// - No PID is tracked (can't determine liveness)
926    /// - The process exists and has the same start time
927    ///
928    /// Returns `false` if:
929    /// - The process no longer exists
930    /// - The PID has been reused by a different process (start time mismatch)
931    pub fn is_process_alive(&self) -> bool {
932        let Some(pid) = self.pid else {
933            // No PID tracked - assume alive (can't determine)
934            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            // No start time recorded - just check if process exists via procfs
940            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        // Check if process exists and has same start time
949        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    /// Records a tool usage.
973    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        // Maintain bounded size using safe VecDeque operations
983        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    /// Increments the update count.
991    pub fn record_update(&mut self) {
992        self.update_count += 1;
993    }
994
995    /// Records an error.
996    pub fn record_error(&mut self, error: &str) {
997        self.last_error = Some(error.to_string());
998    }
999
1000    /// Returns the most recent tool used.
1001    pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1002        self.recent_tools.back()
1003    }
1004
1005    /// Returns recent tools (most recent first).
1006    pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1007        self.recent_tools.iter().rev()
1008    }
1009}
1010
1011/// Reads the process start time using the procfs crate.
1012///
1013/// The start time (in clock ticks since boot) is stable for the lifetime
1014/// of a process and unique enough to detect PID reuse.
1015///
1016/// Returns `None` if the process doesn't exist or can't be read.
1017fn 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// ============================================================================
1030// Application Layer DTO
1031// ============================================================================
1032
1033/// Read-only view of a session for TUI display.
1034///
1035/// Immutable snapshot created from SessionDomain.
1036/// Implements Clone for easy distribution to multiple UI components.
1037#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1038pub struct SessionView {
1039    /// Session identifier
1040    pub id: SessionId,
1041
1042    /// Short ID for display (first 8 chars)
1043    pub id_short: String,
1044
1045    /// Agent type label
1046    pub agent_type: String,
1047
1048    /// Model display name
1049    pub model: String,
1050
1051    /// Current status (3-state model)
1052    pub status: SessionStatus,
1053
1054    /// Status label for display
1055    pub status_label: String,
1056
1057    /// Activity detail (tool name or context)
1058    pub activity_detail: Option<String>,
1059
1060    /// Whether this status should blink
1061    pub should_blink: bool,
1062
1063    /// Status icon
1064    pub status_icon: String,
1065
1066    /// Context usage percentage
1067    pub context_percentage: f64,
1068
1069    /// Context usage formatted string
1070    pub context_display: String,
1071
1072    /// Whether context is in warning state
1073    pub context_warning: bool,
1074
1075    /// Whether context is in critical state
1076    pub context_critical: bool,
1077
1078    /// Cost formatted string
1079    pub cost_display: String,
1080
1081    /// Cost in USD (for sorting)
1082    pub cost_usd: f64,
1083
1084    /// Duration formatted string
1085    pub duration_display: String,
1086
1087    /// Duration in seconds (for sorting)
1088    pub duration_seconds: f64,
1089
1090    /// Lines changed formatted string
1091    pub lines_display: String,
1092
1093    /// Working directory (shortened for display)
1094    pub working_directory: Option<String>,
1095
1096    /// Whether session needs attention (permission wait, high context)
1097    pub needs_attention: bool,
1098
1099    /// Time since last activity (formatted)
1100    pub last_activity_display: String,
1101
1102    /// Session age (formatted)
1103    pub age_display: String,
1104
1105    /// Session start time (ISO 8601)
1106    pub started_at: String,
1107
1108    /// Last activity time (ISO 8601)
1109    pub last_activity: String,
1110
1111    /// Tmux pane ID (e.g., "%5") if session is running in tmux
1112    pub tmux_pane: Option<String>,
1113
1114    /// Git project root (for grouping in tree view)
1115    #[serde(skip_serializing_if = "Option::is_none")]
1116    pub project_root: Option<String>,
1117
1118    /// Git worktree path
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    pub worktree_path: Option<String>,
1121
1122    /// Git branch name for this worktree
1123    #[serde(skip_serializing_if = "Option::is_none")]
1124    pub worktree_branch: Option<String>,
1125
1126    /// Parent session ID (if this is a subagent)
1127    #[serde(skip_serializing_if = "Option::is_none")]
1128    pub parent_session_id: Option<SessionId>,
1129
1130    /// Child subagent session IDs
1131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1132    pub child_session_ids: Vec<SessionId>,
1133
1134    /// First user prompt (for preview summary)
1135    #[serde(skip_serializing_if = "Option::is_none")]
1136    pub first_prompt: Option<String>,
1137}
1138
1139impl SessionView {
1140    /// Creates a SessionView from a SessionDomain.
1141    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                // Shorten path for display
1177                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
1205/// Formats a duration for human-readable display.
1206fn 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    /// Creates a test session with default values.
1230    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        // Even if override is set, known models use their display_name
1311        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        // PreToolUse with interactive tool → AttentionNeeded
1370        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        // PostToolUse → back to Working (thinking)
1383        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        // EnterPlanMode is also interactive
1392        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        // PreToolUse with standard tool → Working
1410        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        // PostToolUse → still Working (thinking)
1423        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        // PreToolUse with None tool name should not change status
1433        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        // Empty string tool name - should be treated as standard tool
1446        // (is_interactive_tool returns false for empty strings)
1447        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        // All three states should exist
1479        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    // ========================================================================
1533    // update_from_status_line cwd change detection
1534    // ========================================================================
1535
1536    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        // working_directory starts as None
1601
1602        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}