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    pub fn update_from_status_line(&mut self, data: &StatusLineData) {
677        self.cost = Money::from_usd(data.cost_usd);
678        self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
679        self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
680        self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
681        self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
682        self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
683        self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
684        self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
685        self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
686        self.last_activity = Utc::now();
687
688        // Status line update means Claude is working
689        // Don't override AttentionNeeded (permission wait)
690        if self.status != SessionStatus::AttentionNeeded {
691            self.status = SessionStatus::Working;
692        }
693    }
694
695    /// Updates status based on a hook event.
696    pub fn apply_hook_event(&mut self, event_type: HookEventType, tool_name: Option<&str>) {
697        self.last_activity = Utc::now();
698
699        match event_type {
700            HookEventType::PreToolUse => {
701                if let Some(name) = tool_name {
702                    if is_interactive_tool(name) {
703                        self.status = SessionStatus::AttentionNeeded;
704                        self.current_activity = Some(ActivityDetail::new(name));
705                    } else {
706                        self.status = SessionStatus::Working;
707                        self.current_activity = Some(ActivityDetail::new(name));
708                    }
709                }
710            }
711            HookEventType::PostToolUse | HookEventType::PostToolUseFailure => {
712                self.status = SessionStatus::Working;
713                self.current_activity = Some(ActivityDetail::thinking());
714            }
715            HookEventType::UserPromptSubmit => {
716                self.status = SessionStatus::Working;
717                self.current_activity = None;
718                // first_prompt is set separately via set_first_prompt()
719            }
720            HookEventType::Stop => {
721                self.status = SessionStatus::Idle;
722                self.current_activity = None;
723            }
724            HookEventType::SessionStart => {
725                self.status = SessionStatus::Idle;
726                self.current_activity = None;
727            }
728            HookEventType::SessionEnd => {
729                // Session will be removed by registry
730                self.status = SessionStatus::Idle;
731                self.current_activity = None;
732            }
733            HookEventType::PreCompact => {
734                self.status = SessionStatus::Working;
735                self.current_activity = Some(ActivityDetail::with_context("Compacting"));
736            }
737            HookEventType::Setup => {
738                self.status = SessionStatus::Working;
739                self.current_activity = Some(ActivityDetail::with_context("Setup"));
740            }
741            HookEventType::Notification => {
742                // Notification handling is done separately with notification_type
743                // This is a fallback - don't change status
744            }
745            HookEventType::SubagentStart | HookEventType::SubagentStop => {
746                // Subagent tracking deferred to future PR
747                self.status = SessionStatus::Working;
748            }
749        }
750    }
751
752    /// Stores the first user prompt if not already set.
753    pub fn set_first_prompt(&mut self, prompt: &str) {
754        if self.first_prompt.is_none() && !prompt.is_empty() {
755            self.first_prompt = Some(prompt.to_string());
756        }
757    }
758
759    /// Updates status based on a notification event.
760    pub fn apply_notification(&mut self, notification_type: Option<&str>) {
761        self.last_activity = Utc::now();
762
763        match notification_type {
764            Some("permission_prompt") => {
765                self.status = SessionStatus::AttentionNeeded;
766                self.current_activity = Some(ActivityDetail::with_context("Permission"));
767            }
768            Some("idle_prompt") => {
769                self.status = SessionStatus::Idle;
770                self.current_activity = None;
771            }
772            Some("elicitation_dialog") => {
773                self.status = SessionStatus::AttentionNeeded;
774                self.current_activity = Some(ActivityDetail::with_context("MCP Input"));
775            }
776            _ => {
777                // Informational notification - no status change
778            }
779        }
780    }
781
782    /// Returns the session age (time since started).
783    pub fn age(&self) -> chrono::Duration {
784        Utc::now().signed_duration_since(self.started_at)
785    }
786
787    /// Returns time since last activity.
788    pub fn time_since_activity(&self) -> chrono::Duration {
789        Utc::now().signed_duration_since(self.last_activity)
790    }
791
792    /// Returns true if context usage needs attention.
793    pub fn needs_context_attention(&self) -> bool {
794        self.context.is_warning() || self.context.is_critical()
795    }
796}
797
798impl Default for SessionDomain {
799    fn default() -> Self {
800        Self::new(
801            SessionId::new("unknown"),
802            AgentType::default(),
803            Model::default(),
804        )
805    }
806}
807
808// ============================================================================
809// Infrastructure Entity
810// ============================================================================
811
812/// Record of a tool invocation.
813#[derive(Debug, Clone)]
814pub struct ToolUsageRecord {
815    /// Name of the tool (e.g., "Bash", "Read", "Write")
816    pub tool_name: String,
817    /// Unique ID for this tool invocation
818    pub tool_use_id: Option<ToolUseId>,
819    /// When the tool was invoked
820    pub timestamp: DateTime<Utc>,
821}
822
823/// Infrastructure-level data for a session.
824///
825/// Contains OS/system concerns that don't belong in the domain model.
826/// Owned by RegistryActor alongside SessionDomain.
827#[derive(Debug, Clone)]
828pub struct SessionInfrastructure {
829    /// Process ID of the Claude Code process (if known)
830    pub pid: Option<u32>,
831
832    /// Process start time in clock ticks (from /proc/{pid}/stat field 22).
833    /// Used to detect PID reuse - if the start time changes, it's a different process.
834    pub process_start_time: Option<u64>,
835
836    /// Path to the Unix socket for this session (if applicable)
837    pub socket_path: Option<PathBuf>,
838
839    /// Path to the transcript JSONL file
840    pub transcript_path: Option<TranscriptPath>,
841
842    /// Recent tool usage history (bounded FIFO queue)
843    pub recent_tools: VecDeque<ToolUsageRecord>,
844
845    /// Number of status updates received
846    pub update_count: u64,
847
848    /// Number of hook events received
849    pub hook_event_count: u64,
850
851    /// Last error encountered (for debugging)
852    pub last_error: Option<String>,
853}
854
855impl SessionInfrastructure {
856    /// Maximum number of tool records to keep.
857    const MAX_TOOL_HISTORY: usize = 50;
858
859    /// Creates new SessionInfrastructure.
860    pub fn new() -> Self {
861        Self {
862            pid: None,
863            process_start_time: None,
864            socket_path: None,
865            transcript_path: None,
866            recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
867            update_count: 0,
868            hook_event_count: 0,
869            last_error: None,
870        }
871    }
872
873    /// Sets the process ID and captures the process start time for PID reuse detection.
874    ///
875    /// The start time is read from `/proc/{pid}/stat` field 22 (starttime in clock ticks).
876    /// If the PID is already set with the same value, this is a no-op.
877    ///
878    /// # Validation
879    ///
880    /// The PID is only stored if:
881    /// - It's non-zero (PID 0 is invalid)
882    /// - We can successfully read its start time from `/proc/{pid}/stat`
883    ///
884    /// This prevents storing invalid PIDs that would cause incorrect liveness checks.
885    pub fn set_pid(&mut self, pid: u32) {
886        // PID 0 is invalid
887        if pid == 0 {
888            return;
889        }
890
891        // Only update if PID changed or wasn't set
892        if self.pid == Some(pid) {
893            return;
894        }
895
896        // Only store PID if we can read and validate its start time
897        // This ensures the PID is valid and gives us PID reuse protection
898        if let Some(start_time) = read_process_start_time(pid) {
899            self.pid = Some(pid);
900            self.process_start_time = Some(start_time);
901        } else {
902            debug!(
903                pid = pid,
904                "PID validation failed - process may have exited or is inaccessible"
905            );
906        }
907    }
908
909    /// Checks if the tracked process is still alive.
910    ///
911    /// Returns `true` if:
912    /// - No PID is tracked (can't determine liveness)
913    /// - The process exists and has the same start time
914    ///
915    /// Returns `false` if:
916    /// - The process no longer exists
917    /// - The PID has been reused by a different process (start time mismatch)
918    pub fn is_process_alive(&self) -> bool {
919        let Some(pid) = self.pid else {
920            // No PID tracked - assume alive (can't determine)
921            debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
922            return true;
923        };
924
925        let Some(expected_start_time) = self.process_start_time else {
926            // No start time recorded - just check if process exists via procfs
927            let exists = procfs::process::Process::new(pid as i32).is_ok();
928            debug!(
929                pid,
930                exists, "is_process_alive: no start_time, checking procfs only"
931            );
932            return exists;
933        };
934
935        // Check if process exists and has same start time
936        match read_process_start_time(pid) {
937            Some(current_start_time) => {
938                let alive = current_start_time == expected_start_time;
939                if !alive {
940                    debug!(
941                        pid,
942                        expected_start_time,
943                        current_start_time,
944                        "is_process_alive: start time MISMATCH - PID reused?"
945                    );
946                }
947                alive
948            }
949            None => {
950                debug!(
951                    pid,
952                    expected_start_time, "is_process_alive: process NOT FOUND in /proc"
953                );
954                false
955            }
956        }
957    }
958
959    /// Records a tool usage.
960    pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
961        let record = ToolUsageRecord {
962            tool_name: tool_name.to_string(),
963            tool_use_id,
964            timestamp: Utc::now(),
965        };
966
967        self.recent_tools.push_back(record);
968
969        // Maintain bounded size using safe VecDeque operations
970        while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
971            self.recent_tools.pop_front();
972        }
973
974        self.hook_event_count += 1;
975    }
976
977    /// Increments the update count.
978    pub fn record_update(&mut self) {
979        self.update_count += 1;
980    }
981
982    /// Records an error.
983    pub fn record_error(&mut self, error: &str) {
984        self.last_error = Some(error.to_string());
985    }
986
987    /// Returns the most recent tool used.
988    pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
989        self.recent_tools.back()
990    }
991
992    /// Returns recent tools (most recent first).
993    pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
994        self.recent_tools.iter().rev()
995    }
996}
997
998/// Reads the process start time using the procfs crate.
999///
1000/// The start time (in clock ticks since boot) is stable for the lifetime
1001/// of a process and unique enough to detect PID reuse.
1002///
1003/// Returns `None` if the process doesn't exist or can't be read.
1004fn read_process_start_time(pid: u32) -> Option<u64> {
1005    let process = procfs::process::Process::new(pid as i32).ok()?;
1006    let stat = process.stat().ok()?;
1007    Some(stat.starttime)
1008}
1009
1010impl Default for SessionInfrastructure {
1011    fn default() -> Self {
1012        Self::new()
1013    }
1014}
1015
1016// ============================================================================
1017// Application Layer DTO
1018// ============================================================================
1019
1020/// Read-only view of a session for TUI display.
1021///
1022/// Immutable snapshot created from SessionDomain.
1023/// Implements Clone for easy distribution to multiple UI components.
1024#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1025pub struct SessionView {
1026    /// Session identifier
1027    pub id: SessionId,
1028
1029    /// Short ID for display (first 8 chars)
1030    pub id_short: String,
1031
1032    /// Agent type label
1033    pub agent_type: String,
1034
1035    /// Model display name
1036    pub model: String,
1037
1038    /// Current status (3-state model)
1039    pub status: SessionStatus,
1040
1041    /// Status label for display
1042    pub status_label: String,
1043
1044    /// Activity detail (tool name or context)
1045    pub activity_detail: Option<String>,
1046
1047    /// Whether this status should blink
1048    pub should_blink: bool,
1049
1050    /// Status icon
1051    pub status_icon: String,
1052
1053    /// Context usage percentage
1054    pub context_percentage: f64,
1055
1056    /// Context usage formatted string
1057    pub context_display: String,
1058
1059    /// Whether context is in warning state
1060    pub context_warning: bool,
1061
1062    /// Whether context is in critical state
1063    pub context_critical: bool,
1064
1065    /// Cost formatted string
1066    pub cost_display: String,
1067
1068    /// Cost in USD (for sorting)
1069    pub cost_usd: f64,
1070
1071    /// Duration formatted string
1072    pub duration_display: String,
1073
1074    /// Duration in seconds (for sorting)
1075    pub duration_seconds: f64,
1076
1077    /// Lines changed formatted string
1078    pub lines_display: String,
1079
1080    /// Working directory (shortened for display)
1081    pub working_directory: Option<String>,
1082
1083    /// Whether session needs attention (permission wait, high context)
1084    pub needs_attention: bool,
1085
1086    /// Time since last activity (formatted)
1087    pub last_activity_display: String,
1088
1089    /// Session age (formatted)
1090    pub age_display: String,
1091
1092    /// Session start time (ISO 8601)
1093    pub started_at: String,
1094
1095    /// Last activity time (ISO 8601)
1096    pub last_activity: String,
1097
1098    /// Tmux pane ID (e.g., "%5") if session is running in tmux
1099    pub tmux_pane: Option<String>,
1100
1101    /// Git project root (for grouping in tree view)
1102    #[serde(skip_serializing_if = "Option::is_none")]
1103    pub project_root: Option<String>,
1104
1105    /// Git worktree path
1106    #[serde(skip_serializing_if = "Option::is_none")]
1107    pub worktree_path: Option<String>,
1108
1109    /// Git branch name for this worktree
1110    #[serde(skip_serializing_if = "Option::is_none")]
1111    pub worktree_branch: Option<String>,
1112
1113    /// Parent session ID (if this is a subagent)
1114    #[serde(skip_serializing_if = "Option::is_none")]
1115    pub parent_session_id: Option<SessionId>,
1116
1117    /// Child subagent session IDs
1118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1119    pub child_session_ids: Vec<SessionId>,
1120
1121    /// First user prompt (for preview summary)
1122    #[serde(skip_serializing_if = "Option::is_none")]
1123    pub first_prompt: Option<String>,
1124}
1125
1126impl SessionView {
1127    /// Creates a SessionView from a SessionDomain.
1128    pub fn from_domain(session: &SessionDomain) -> Self {
1129        let now = Utc::now();
1130        let since_activity = now.signed_duration_since(session.last_activity);
1131        let age = now.signed_duration_since(session.started_at);
1132
1133        Self {
1134            id: session.id.clone(),
1135            id_short: session.id.short().to_string(),
1136            agent_type: session.agent_type.short_name().to_string(),
1137            model: if session.model.is_unknown() {
1138                session
1139                    .model_display_override
1140                    .clone()
1141                    .unwrap_or_else(|| session.model.display_name().to_string())
1142            } else {
1143                session.model.display_name().to_string()
1144            },
1145            status: session.status,
1146            status_label: session.status.label().to_string(),
1147            activity_detail: session
1148                .current_activity
1149                .as_ref()
1150                .map(|a| a.display().into_owned()),
1151            should_blink: session.status.should_blink(),
1152            status_icon: session.status.icon().to_string(),
1153            context_percentage: session.context.usage_percentage(),
1154            context_display: session.context.format(),
1155            context_warning: session.context.is_warning(),
1156            context_critical: session.context.is_critical(),
1157            cost_display: session.cost.format(),
1158            cost_usd: session.cost.as_usd(),
1159            duration_display: session.duration.format(),
1160            duration_seconds: session.duration.total_seconds(),
1161            lines_display: session.lines_changed.format(),
1162            working_directory: session.working_directory.clone().map(|p| {
1163                // Shorten path for display
1164                if p.len() > 30 {
1165                    format!("...{}", &p[p.len().saturating_sub(27)..])
1166                } else {
1167                    p
1168                }
1169            }),
1170            needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1171            last_activity_display: format_duration(since_activity),
1172            age_display: format_duration(age),
1173            started_at: session.started_at.to_rfc3339(),
1174            last_activity: session.last_activity.to_rfc3339(),
1175            tmux_pane: session.tmux_pane.clone(),
1176            project_root: session.project_root.clone(),
1177            worktree_path: session.worktree_path.clone(),
1178            worktree_branch: session.worktree_branch.clone(),
1179            parent_session_id: session.parent_session_id.clone(),
1180            child_session_ids: session.child_session_ids.clone(),
1181            first_prompt: session.first_prompt.clone(),
1182        }
1183    }
1184}
1185
1186impl From<&SessionDomain> for SessionView {
1187    fn from(session: &SessionDomain) -> Self {
1188        Self::from_domain(session)
1189    }
1190}
1191
1192/// Formats a duration for human-readable display.
1193fn format_duration(duration: chrono::Duration) -> String {
1194    let secs = duration.num_seconds();
1195    if secs < 0 {
1196        return "now".to_string();
1197    }
1198    if secs < 60 {
1199        format!("{secs}s ago")
1200    } else if secs < 3600 {
1201        let mins = secs / 60;
1202        format!("{mins}m ago")
1203    } else if secs < 86400 {
1204        let hours = secs / 3600;
1205        format!("{hours}h ago")
1206    } else {
1207        let days = secs / 86400;
1208        format!("{days}d ago")
1209    }
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214    use super::*;
1215
1216    /// Creates a test session with default values.
1217    fn create_test_session(id: &str) -> SessionDomain {
1218        SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1219    }
1220
1221    #[test]
1222    fn test_session_id_short() {
1223        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1224        assert_eq!(id.short(), "8e11bfb5");
1225    }
1226
1227    #[test]
1228    fn test_session_id_short_short_id() {
1229        let id = SessionId::new("abc");
1230        assert_eq!(id.short(), "abc");
1231    }
1232
1233    #[test]
1234    fn test_session_status_display() {
1235        let status = SessionStatus::Working;
1236        assert_eq!(format!("{status}"), "Working");
1237    }
1238
1239    #[test]
1240    fn test_session_domain_creation() {
1241        let session = SessionDomain::new(
1242            SessionId::new("test-123"),
1243            AgentType::GeneralPurpose,
1244            Model::Opus45,
1245        );
1246        assert_eq!(session.id.as_str(), "test-123");
1247        assert_eq!(session.model, Model::Opus45);
1248        assert!(session.cost.is_zero());
1249    }
1250
1251    #[test]
1252    fn test_session_view_from_domain() {
1253        let session = SessionDomain::new(
1254            SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1255            AgentType::Explore,
1256            Model::Sonnet4,
1257        );
1258        let view = SessionView::from_domain(&session);
1259
1260        assert_eq!(view.id_short, "8e11bfb5");
1261        assert_eq!(view.agent_type, "explore");
1262        assert_eq!(view.model, "Sonnet 4");
1263    }
1264
1265    #[test]
1266    fn test_session_view_unknown_model_with_override() {
1267        let mut session = SessionDomain::new(
1268            SessionId::new("test-override"),
1269            AgentType::GeneralPurpose,
1270            Model::Unknown,
1271        );
1272        session.model_display_override = Some("GPT-4o".to_string());
1273
1274        let view = SessionView::from_domain(&session);
1275        assert_eq!(view.model, "GPT-4o");
1276    }
1277
1278    #[test]
1279    fn test_session_view_unknown_model_without_override() {
1280        let session = SessionDomain::new(
1281            SessionId::new("test-no-override"),
1282            AgentType::GeneralPurpose,
1283            Model::Unknown,
1284        );
1285
1286        let view = SessionView::from_domain(&session);
1287        assert_eq!(view.model, "Unknown");
1288    }
1289
1290    #[test]
1291    fn test_session_view_known_model_ignores_override() {
1292        let mut session = SessionDomain::new(
1293            SessionId::new("test-known"),
1294            AgentType::GeneralPurpose,
1295            Model::Opus46,
1296        );
1297        // Even if override is set, known models use their display_name
1298        session.model_display_override = Some("something else".to_string());
1299
1300        let view = SessionView::from_domain(&session);
1301        assert_eq!(view.model, "Opus 4.6");
1302    }
1303
1304    #[test]
1305    fn test_lines_changed() {
1306        let lines = LinesChanged::new(150, 30);
1307        assert_eq!(lines.net(), 120);
1308        assert_eq!(lines.churn(), 180);
1309        assert_eq!(lines.format(), "+150 -30");
1310        assert_eq!(lines.format_net(), "+120");
1311    }
1312
1313    #[test]
1314    fn test_session_duration_formatting() {
1315        assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1316        assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1317        assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1318    }
1319
1320    #[test]
1321    fn test_session_id_pending_from_pid() {
1322        let id = SessionId::pending_from_pid(12345);
1323        assert_eq!(id.as_str(), "pending-12345");
1324        assert!(id.is_pending());
1325        assert_eq!(id.pending_pid(), Some(12345));
1326    }
1327
1328    #[test]
1329    fn test_session_id_is_pending_true() {
1330        let id = SessionId::new("pending-99999");
1331        assert!(id.is_pending());
1332    }
1333
1334    #[test]
1335    fn test_session_id_is_pending_false() {
1336        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1337        assert!(!id.is_pending());
1338    }
1339
1340    #[test]
1341    fn test_session_id_pending_pid_returns_none_for_regular_id() {
1342        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1343        assert_eq!(id.pending_pid(), None);
1344    }
1345
1346    #[test]
1347    fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1348        let id = SessionId::new("pending-not-a-number");
1349        assert_eq!(id.pending_pid(), None);
1350    }
1351
1352    #[test]
1353    fn test_apply_hook_event_interactive_tool() {
1354        let mut session = create_test_session("test-interactive");
1355
1356        // PreToolUse with interactive tool → AttentionNeeded
1357        session.apply_hook_event(HookEventType::PreToolUse, Some("AskUserQuestion"));
1358
1359        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1360        assert_eq!(
1361            session
1362                .current_activity
1363                .as_ref()
1364                .map(|a| a.display())
1365                .as_deref(),
1366            Some("AskUserQuestion")
1367        );
1368
1369        // PostToolUse → back to Working (thinking)
1370        session.apply_hook_event(HookEventType::PostToolUse, None);
1371        assert_eq!(session.status, SessionStatus::Working);
1372    }
1373
1374    #[test]
1375    fn test_apply_hook_event_enter_plan_mode() {
1376        let mut session = create_test_session("test-plan");
1377
1378        // EnterPlanMode is also interactive
1379        session.apply_hook_event(HookEventType::PreToolUse, Some("EnterPlanMode"));
1380
1381        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1382        assert_eq!(
1383            session
1384                .current_activity
1385                .as_ref()
1386                .map(|a| a.display())
1387                .as_deref(),
1388            Some("EnterPlanMode")
1389        );
1390    }
1391
1392    #[test]
1393    fn test_apply_hook_event_standard_tool() {
1394        let mut session = create_test_session("test-standard");
1395
1396        // PreToolUse with standard tool → Working
1397        session.apply_hook_event(HookEventType::PreToolUse, Some("Bash"));
1398
1399        assert_eq!(session.status, SessionStatus::Working);
1400        assert_eq!(
1401            session
1402                .current_activity
1403                .as_ref()
1404                .map(|a| a.display())
1405                .as_deref(),
1406            Some("Bash")
1407        );
1408
1409        // PostToolUse → still Working (thinking)
1410        session.apply_hook_event(HookEventType::PostToolUse, Some("Bash"));
1411        assert_eq!(session.status, SessionStatus::Working);
1412    }
1413
1414    #[test]
1415    fn test_apply_hook_event_none_tool_name() {
1416        let mut session = create_test_session("test-none");
1417        let original_status = session.status;
1418
1419        // PreToolUse with None tool name should not change status
1420        session.apply_hook_event(HookEventType::PreToolUse, None);
1421
1422        assert_eq!(
1423            session.status, original_status,
1424            "PreToolUse with None tool_name should not change status"
1425        );
1426    }
1427
1428    #[test]
1429    fn test_apply_hook_event_empty_tool_name() {
1430        let mut session = create_test_session("test-empty");
1431
1432        // Empty string tool name - should be treated as standard tool
1433        // (is_interactive_tool returns false for empty strings)
1434        session.apply_hook_event(HookEventType::PreToolUse, Some(""));
1435
1436        assert_eq!(session.status, SessionStatus::Working);
1437    }
1438
1439    #[test]
1440    fn test_activity_detail_creation() {
1441        let detail = ActivityDetail::new("Bash");
1442        assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1443        assert!(detail.started_at <= Utc::now());
1444        assert!(detail.context.is_none());
1445    }
1446
1447    #[test]
1448    fn test_activity_detail_with_context() {
1449        let detail = ActivityDetail::with_context("Compacting");
1450        assert!(detail.tool_name.is_none());
1451        assert_eq!(detail.context.as_deref(), Some("Compacting"));
1452    }
1453
1454    #[test]
1455    fn test_activity_detail_display() {
1456        let detail = ActivityDetail::new("Read");
1457        assert_eq!(detail.display(), "Read");
1458
1459        let context_detail = ActivityDetail::with_context("Setup");
1460        assert_eq!(context_detail.display(), "Setup");
1461    }
1462
1463    #[test]
1464    fn test_new_session_status_variants() {
1465        // All three states should exist
1466        let idle = SessionStatus::Idle;
1467        let working = SessionStatus::Working;
1468        let attention = SessionStatus::AttentionNeeded;
1469
1470        assert_eq!(idle.label(), "idle");
1471        assert_eq!(working.label(), "working");
1472        assert_eq!(attention.label(), "needs input");
1473    }
1474
1475    #[test]
1476    fn test_session_status_should_blink() {
1477        assert!(!SessionStatus::Idle.should_blink());
1478        assert!(!SessionStatus::Working.should_blink());
1479        assert!(SessionStatus::AttentionNeeded.should_blink());
1480    }
1481
1482    #[test]
1483    fn test_session_status_icons() {
1484        assert_eq!(SessionStatus::Idle.icon(), "-");
1485        assert_eq!(SessionStatus::Working.icon(), ">");
1486        assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1487    }
1488
1489    #[test]
1490    fn test_session_domain_new_fields_default() {
1491        let session = create_test_session("test-defaults");
1492        assert!(session.project_root.is_none());
1493        assert!(session.worktree_path.is_none());
1494        assert!(session.worktree_branch.is_none());
1495        assert!(session.parent_session_id.is_none());
1496        assert!(session.child_session_ids.is_empty());
1497    }
1498
1499    #[test]
1500    fn test_session_view_includes_new_fields() {
1501        let mut session = create_test_session("test-view-fields");
1502        session.project_root = Some("/home/user/project".to_string());
1503        session.worktree_path = Some("/home/user/worktree".to_string());
1504        session.worktree_branch = Some("feature-x".to_string());
1505        session.parent_session_id = Some(SessionId::new("parent-123"));
1506        session.child_session_ids = vec![SessionId::new("child-1"), SessionId::new("child-2")];
1507
1508        let view = SessionView::from_domain(&session);
1509
1510        assert_eq!(view.project_root, Some("/home/user/project".to_string()));
1511        assert_eq!(view.worktree_path, Some("/home/user/worktree".to_string()));
1512        assert_eq!(view.worktree_branch, Some("feature-x".to_string()));
1513        assert_eq!(view.parent_session_id, Some(SessionId::new("parent-123")));
1514        assert_eq!(view.child_session_ids.len(), 2);
1515        assert_eq!(view.child_session_ids[0].as_str(), "child-1");
1516        assert_eq!(view.child_session_ids[1].as_str(), "child-2");
1517    }
1518}