Skip to main content

atm_core/
session.rs

1//! Session domain entities and value objects.
2
3use crate::lifecycle::{LifecycleEvent, NeedsInputReason, NotificationKind};
4use crate::{AgentType, ContextUsage, Model, Money, TokenCount};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8use std::collections::VecDeque;
9use std::fmt;
10use std::path::{Path, PathBuf};
11use tracing::debug;
12
13// ============================================================================
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    /// Which coding-agent harness drives this session (Claude Code,
530    /// pi, future). Distinct from `agent_type` (which today encodes
531    /// Claude subagent role) — see `crate::harness::Harness`.
532    #[serde(default)]
533    pub harness: crate::Harness,
534
535    /// Claude model being used
536    pub model: Model,
537
538    /// Display name override for unknown/non-Anthropic models.
539    /// When `model` is `Unknown`, this holds the raw model ID or
540    /// the provider-supplied display name for UI rendering.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub model_display_override: Option<String>,
543
544    /// Current session status (3-state model)
545    pub status: SessionStatus,
546
547    /// Current activity details (tool name, context, timing)
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub current_activity: Option<ActivityDetail>,
550
551    /// Context window usage
552    pub context: ContextUsage,
553
554    /// Accumulated cost
555    pub cost: Money,
556
557    /// Session duration tracking
558    pub duration: SessionDuration,
559
560    /// Lines of code changed
561    pub lines_changed: LinesChanged,
562
563    /// When the session started
564    pub started_at: DateTime<Utc>,
565
566    /// Last activity timestamp
567    pub last_activity: DateTime<Utc>,
568
569    /// Working directory (project root)
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub working_directory: Option<String>,
572
573    /// Claude Code version
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub claude_code_version: Option<String>,
576
577    /// Tmux pane ID (e.g., "%5") if session is running in tmux
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub tmux_pane: Option<String>,
580
581    /// Git project root (resolved from working_directory).
582    /// Shared across all worktrees of the same repo.
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub project_root: Option<String>,
585
586    /// Git worktree path (specific checkout directory).
587    /// For the main checkout, this equals project_root.
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub worktree_path: Option<String>,
590
591    /// Git branch name for this worktree.
592    #[serde(skip_serializing_if = "Option::is_none")]
593    pub worktree_branch: Option<String>,
594
595    /// Parent session ID (set when this session is a subagent).
596    #[serde(skip_serializing_if = "Option::is_none")]
597    pub parent_session_id: Option<SessionId>,
598
599    /// Child subagent session IDs spawned by this session.
600    #[serde(default, skip_serializing_if = "Vec::is_empty")]
601    pub child_session_ids: Vec<SessionId>,
602
603    /// First user prompt (captured from the first UserPromptSubmit hook event).
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub first_prompt: Option<String>,
606}
607
608impl SessionDomain {
609    /// Creates a new SessionDomain with required fields.
610    pub fn new(id: SessionId, agent_type: AgentType, model: Model) -> Self {
611        let now = Utc::now();
612        Self {
613            id,
614            agent_type,
615            harness: crate::Harness::default(),
616            model,
617            model_display_override: None,
618            status: SessionStatus::Idle,
619            current_activity: None,
620            context: ContextUsage::new(model.context_window_size()),
621            cost: Money::zero(),
622            duration: SessionDuration::default(),
623            lines_changed: LinesChanged::default(),
624            started_at: now,
625            last_activity: now,
626            working_directory: None,
627            claude_code_version: None,
628            tmux_pane: None,
629            project_root: None,
630            worktree_path: None,
631            worktree_branch: None,
632            parent_session_id: None,
633            child_session_ids: Vec::new(),
634            first_prompt: None,
635        }
636    }
637
638    /// Creates a SessionDomain from Claude Code status line data.
639    pub fn from_status_line(data: &StatusLineData) -> Self {
640        use crate::model::derive_display_name;
641
642        let model = Model::from_id(&data.model_id);
643
644        let mut session = Self::new(
645            SessionId::new(&data.session_id),
646            AgentType::GeneralPurpose, // Default, may be updated by hook events
647            model,
648        );
649        // Status-line input is only emitted by Claude Code today.
650        session.harness = crate::Harness::ClaudeCode;
651
652        // For unknown models, store a display name fallback:
653        // prefer provider-supplied display_name, then derive from raw ID
654        if model.is_unknown() && !data.model_id.is_empty() {
655            session.model_display_override = Some(
656                data.model_display_name
657                    .clone()
658                    .unwrap_or_else(|| derive_display_name(&data.model_id)),
659            );
660        }
661
662        session.cost = Money::from_usd(data.cost_usd);
663        session.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
664        session.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
665        session.context = ContextUsage {
666            total_input_tokens: TokenCount::new(data.total_input_tokens),
667            total_output_tokens: TokenCount::new(data.total_output_tokens),
668            context_window_size: data.context_window_size,
669            current_input_tokens: TokenCount::new(data.current_input_tokens),
670            current_output_tokens: TokenCount::new(data.current_output_tokens),
671            cache_creation_tokens: TokenCount::new(data.cache_creation_tokens),
672            cache_read_tokens: TokenCount::new(data.cache_read_tokens),
673        };
674        session.working_directory = data.cwd.clone();
675        session.claude_code_version = data.version.clone();
676        session.last_activity = Utc::now();
677
678        session
679    }
680
681    /// Updates the session with new status line data.
682    ///
683    /// When `current_usage` is null in Claude's status line, all current_* values
684    /// will be 0, which correctly resets context percentage to 0%.
685    ///
686    /// Returns `true` if the working directory changed (caller should re-resolve git info).
687    pub fn update_from_status_line(&mut self, data: &StatusLineData) -> bool {
688        self.cost = Money::from_usd(data.cost_usd);
689        self.duration = SessionDuration::new(data.total_duration_ms, data.api_duration_ms);
690        self.lines_changed = LinesChanged::new(data.lines_added, data.lines_removed);
691        self.context.total_input_tokens = TokenCount::new(data.total_input_tokens);
692        self.context.total_output_tokens = TokenCount::new(data.total_output_tokens);
693        self.context.current_input_tokens = TokenCount::new(data.current_input_tokens);
694        self.context.current_output_tokens = TokenCount::new(data.current_output_tokens);
695        self.context.cache_creation_tokens = TokenCount::new(data.cache_creation_tokens);
696        self.context.cache_read_tokens = TokenCount::new(data.cache_read_tokens);
697        self.last_activity = Utc::now();
698
699        // Status line update means Claude is working
700        // Don't override AttentionNeeded (permission wait)
701        if self.status != SessionStatus::AttentionNeeded {
702            self.status = SessionStatus::Working;
703        }
704
705        // Detect working directory change
706        let cwd_changed = match (&data.cwd, &self.working_directory) {
707            (Some(new_cwd), Some(old_cwd)) => new_cwd != old_cwd,
708            (Some(_), None) => true,
709            _ => false,
710        };
711        if cwd_changed {
712            self.working_directory = data.cwd.clone();
713        }
714        cwd_changed
715    }
716
717    /// Updates status from a vendor-neutral lifecycle event.
718    ///
719    /// Single source of truth for session-state transitions. Every
720    /// adapter (Claude, pi, future) funnels through this method.
721    pub fn apply_lifecycle_event(&mut self, event: &LifecycleEvent) {
722        self.last_activity = Utc::now();
723
724        match event {
725            LifecycleEvent::SessionStart { .. } => {
726                self.status = SessionStatus::Idle;
727                self.current_activity = None;
728            }
729            LifecycleEvent::SessionEnd { .. } => {
730                // Registry removes the session; this status is rarely
731                // observed, but keep it consistent.
732                self.status = SessionStatus::Idle;
733                self.current_activity = None;
734            }
735            LifecycleEvent::WorkingStart => {
736                self.status = SessionStatus::Working;
737                self.current_activity = None;
738            }
739            LifecycleEvent::WorkingEnd | LifecycleEvent::Idle => {
740                self.status = SessionStatus::Idle;
741                self.current_activity = None;
742            }
743            LifecycleEvent::PromptSubmit { .. } => {
744                self.status = SessionStatus::Working;
745                self.current_activity = None;
746                // first_prompt is set separately via set_first_prompt()
747            }
748            LifecycleEvent::NeedsInput { reason } => {
749                self.status = SessionStatus::AttentionNeeded;
750                self.current_activity = Some(activity_for_needs_input(reason));
751            }
752            LifecycleEvent::ToolCallStart { name, .. } => {
753                self.status = SessionStatus::Working;
754                self.current_activity = Some(ActivityDetail::new(name.as_str()));
755            }
756            LifecycleEvent::ToolCallEnd { .. } => {
757                self.status = SessionStatus::Working;
758                self.current_activity = Some(ActivityDetail::thinking());
759            }
760            LifecycleEvent::ContextCompactStart { .. } => {
761                self.status = SessionStatus::Working;
762                self.current_activity = Some(ActivityDetail::with_context("Compacting"));
763            }
764            LifecycleEvent::ContextUpdate { tokens, cost_usd } => {
765                // Pi (and future vendors) emit cumulative cost/tokens
766                // through this variant — there's no "status line"
767                // periodic update like Claude. Fold the values into
768                // the same `Session.cost` / `Session.context` fields
769                // the Claude path uses, so the TUI displays them
770                // identically regardless of vendor.
771                if let Some(c) = cost_usd {
772                    self.cost = Money::from_usd(*c);
773                }
774                if let Some(t) = tokens {
775                    // Pi reports cumulative total tokens for the
776                    // session. The TUI's percentage display reads
777                    // `context_tokens()` which sums Claude's
778                    // current_input + cache_read + cache_creation —
779                    // none of which pi populates. To make pi sessions
780                    // surface a non-zero context bar, mirror pi's
781                    // cumulative figure into `current_input_tokens`
782                    // (the largest summand of `context_tokens()`).
783                    // Also keep `total_input_tokens` set for the
784                    // detail-panel "total tokens" display, even though
785                    // it doesn't affect the percentage.
786                    let count = TokenCount::new(*t);
787                    self.context.current_input_tokens = count;
788                    self.context.total_input_tokens = count;
789                }
790                // Status unchanged: cost/token updates don't
791                // imply a state transition.
792            }
793            LifecycleEvent::ProviderModelChange { model, .. } => {
794                // Pi's `model_select` event fires when the user picks a
795                // provider/model in pi's UI. Update Session so the TUI
796                // stops showing `[pi] Unknown` once the user has chosen.
797                //
798                // Strategy: try to map the raw id onto our Claude-shaped
799                // `Model` enum first (in case it's a Claude model pi is
800                // talking to); fall back to `Model::Unknown` and stash
801                // the raw id in `model_display_override` for rendering.
802                if let Some(id) = model {
803                    let parsed = Model::from_id(id);
804                    self.model = parsed;
805                    self.model_display_override = if parsed.is_unknown() {
806                        Some(crate::model::derive_display_name(id))
807                    } else {
808                        None
809                    };
810                }
811                // Status unchanged: model selection is metadata only.
812            }
813            LifecycleEvent::Notification { kind, .. } => {
814                if matches!(kind, Some(NotificationKind::Setup)) {
815                    self.status = SessionStatus::Working;
816                    self.current_activity = Some(ActivityDetail::with_context("Setup"));
817                }
818                // Other notifications: no status change. Permission /
819                // elicitation prompts arrive as `NeedsInput`, not
820                // `Notification`, after translation.
821            }
822            LifecycleEvent::ChildSessionStart { .. } | LifecycleEvent::ChildSessionEnd { .. } => {
823                // Child-session correlation is tracked by the registry
824                // (subagent pending-list); status remains Working.
825                self.status = SessionStatus::Working;
826            }
827        }
828    }
829
830    /// Stores the first user prompt if not already set.
831    pub fn set_first_prompt_from_event(&mut self, event: &LifecycleEvent) {
832        if let LifecycleEvent::PromptSubmit { prompt: Some(text) } = event {
833            if !text.is_empty() {
834                self.set_first_prompt(text);
835            }
836        }
837    }
838
839    /// Stores the first user prompt if not already set.
840    pub fn set_first_prompt(&mut self, prompt: &str) {
841        if self.first_prompt.is_none() && !prompt.is_empty() {
842            self.first_prompt = Some(prompt.to_string());
843        }
844    }
845
846    /// Returns the session age (time since started).
847    pub fn age(&self) -> chrono::Duration {
848        Utc::now().signed_duration_since(self.started_at)
849    }
850
851    /// Returns time since last activity.
852    pub fn time_since_activity(&self) -> chrono::Duration {
853        Utc::now().signed_duration_since(self.last_activity)
854    }
855
856    /// Returns true if context usage needs attention.
857    pub fn needs_context_attention(&self) -> bool {
858        self.context.is_warning() || self.context.is_critical()
859    }
860}
861
862impl Default for SessionDomain {
863    fn default() -> Self {
864        Self::new(
865            SessionId::new("unknown"),
866            AgentType::default(),
867            Model::default(),
868        )
869    }
870}
871
872// ============================================================================
873// Infrastructure Entity
874// ============================================================================
875
876/// Record of a tool invocation.
877#[derive(Debug, Clone)]
878pub struct ToolUsageRecord {
879    /// Name of the tool (e.g., "Bash", "Read", "Write")
880    pub tool_name: String,
881    /// Unique ID for this tool invocation
882    pub tool_use_id: Option<ToolUseId>,
883    /// When the tool was invoked
884    pub timestamp: DateTime<Utc>,
885}
886
887/// Infrastructure-level data for a session.
888///
889/// Contains OS/system concerns that don't belong in the domain model.
890/// Owned by RegistryActor alongside SessionDomain.
891#[derive(Debug, Clone)]
892pub struct SessionInfrastructure {
893    /// Process ID of the Claude Code process (if known)
894    pub pid: Option<u32>,
895
896    /// Process start time in clock ticks (from /proc/{pid}/stat field 22).
897    /// Used to detect PID reuse - if the start time changes, it's a different process.
898    pub process_start_time: Option<u64>,
899
900    /// Path to the Unix socket for this session (if applicable)
901    pub socket_path: Option<PathBuf>,
902
903    /// Path to the transcript JSONL file
904    pub transcript_path: Option<TranscriptPath>,
905
906    /// Recent tool usage history (bounded FIFO queue)
907    pub recent_tools: VecDeque<ToolUsageRecord>,
908
909    /// Number of status updates received
910    pub update_count: u64,
911
912    /// Number of hook events received
913    pub hook_event_count: u64,
914
915    /// Last error encountered (for debugging)
916    pub last_error: Option<String>,
917}
918
919impl SessionInfrastructure {
920    /// Maximum number of tool records to keep.
921    const MAX_TOOL_HISTORY: usize = 50;
922
923    /// Creates new SessionInfrastructure.
924    pub fn new() -> Self {
925        Self {
926            pid: None,
927            process_start_time: None,
928            socket_path: None,
929            transcript_path: None,
930            recent_tools: VecDeque::with_capacity(Self::MAX_TOOL_HISTORY),
931            update_count: 0,
932            hook_event_count: 0,
933            last_error: None,
934        }
935    }
936
937    /// Sets the process ID and captures the process start time for PID reuse detection.
938    ///
939    /// The start time is read from `/proc/{pid}/stat` field 22 (starttime in clock ticks).
940    /// If the PID is already set with the same value, this is a no-op.
941    ///
942    /// # Validation
943    ///
944    /// The PID is only stored if:
945    /// - It's non-zero (PID 0 is invalid)
946    /// - We can successfully read its start time from `/proc/{pid}/stat`
947    ///
948    /// This prevents storing invalid PIDs that would cause incorrect liveness checks.
949    pub fn set_pid(&mut self, pid: u32) {
950        // PID 0 is invalid
951        if pid == 0 {
952            return;
953        }
954
955        // Only update if PID changed or wasn't set
956        if self.pid == Some(pid) {
957            return;
958        }
959
960        // Only store PID if we can read and validate its start time
961        // This ensures the PID is valid and gives us PID reuse protection
962        if let Some(start_time) = read_process_start_time(pid) {
963            self.pid = Some(pid);
964            self.process_start_time = Some(start_time);
965        } else {
966            debug!(
967                pid = pid,
968                "PID validation failed - process may have exited or is inaccessible"
969            );
970        }
971    }
972
973    /// Checks if the tracked process is still alive.
974    ///
975    /// Returns `true` if:
976    /// - No PID is tracked (can't determine liveness)
977    /// - The process exists and has the same start time
978    ///
979    /// Returns `false` if:
980    /// - The process no longer exists
981    /// - The PID has been reused by a different process (start time mismatch)
982    pub fn is_process_alive(&self) -> bool {
983        let Some(pid) = self.pid else {
984            // No PID tracked - assume alive (can't determine)
985            debug!(pid = ?self.pid, "is_process_alive: no PID tracked, assuming alive");
986            return true;
987        };
988
989        let Some(expected_start_time) = self.process_start_time else {
990            // No start time recorded - just check if process exists via procfs
991            let exists = procfs::process::Process::new(pid as i32).is_ok();
992            debug!(
993                pid,
994                exists, "is_process_alive: no start_time, checking procfs only"
995            );
996            return exists;
997        };
998
999        // Check if process exists and has same start time
1000        match read_process_start_time(pid) {
1001            Some(current_start_time) => {
1002                let alive = current_start_time == expected_start_time;
1003                if !alive {
1004                    debug!(
1005                        pid,
1006                        expected_start_time,
1007                        current_start_time,
1008                        "is_process_alive: start time MISMATCH - PID reused?"
1009                    );
1010                }
1011                alive
1012            }
1013            None => {
1014                debug!(
1015                    pid,
1016                    expected_start_time, "is_process_alive: process NOT FOUND in /proc"
1017                );
1018                false
1019            }
1020        }
1021    }
1022
1023    /// Records a tool usage.
1024    pub fn record_tool_use(&mut self, tool_name: &str, tool_use_id: Option<ToolUseId>) {
1025        let record = ToolUsageRecord {
1026            tool_name: tool_name.to_string(),
1027            tool_use_id,
1028            timestamp: Utc::now(),
1029        };
1030
1031        self.recent_tools.push_back(record);
1032
1033        // Maintain bounded size using safe VecDeque operations
1034        while self.recent_tools.len() > Self::MAX_TOOL_HISTORY {
1035            self.recent_tools.pop_front();
1036        }
1037
1038        self.hook_event_count += 1;
1039    }
1040
1041    /// Increments the update count.
1042    pub fn record_update(&mut self) {
1043        self.update_count += 1;
1044    }
1045
1046    /// Records an error.
1047    pub fn record_error(&mut self, error: &str) {
1048        self.last_error = Some(error.to_string());
1049    }
1050
1051    /// Returns the most recent tool used.
1052    pub fn last_tool(&self) -> Option<&ToolUsageRecord> {
1053        self.recent_tools.back()
1054    }
1055
1056    /// Returns recent tools (most recent first).
1057    pub fn recent_tools_iter(&self) -> impl Iterator<Item = &ToolUsageRecord> {
1058        self.recent_tools.iter().rev()
1059    }
1060}
1061
1062/// Activity-detail string for an `AttentionNeeded` state.
1063fn activity_for_needs_input(reason: &NeedsInputReason) -> ActivityDetail {
1064    match reason {
1065        NeedsInputReason::InteractiveTool { tool } | NeedsInputReason::PermissionGate { tool } => {
1066            ActivityDetail::new(tool.as_str())
1067        }
1068        NeedsInputReason::Notification { kind, label } => {
1069            // When the vendor-supplied label is present (e.g. pi
1070            // forwards the dialog title from `ctx.ui.select`), prefer
1071            // it over the kind-derived static string — it tells the
1072            // user what's actually being asked.
1073            if let Some(text) = label.as_deref() {
1074                return ActivityDetail::with_context(text);
1075            }
1076            match kind {
1077                NotificationKind::PermissionPrompt => ActivityDetail::with_context("Permission"),
1078                NotificationKind::ElicitationDialog => ActivityDetail::with_context("MCP Input"),
1079                other => ActivityDetail::with_context(other.as_str()),
1080            }
1081        }
1082    }
1083}
1084
1085/// Reads the process start time using the procfs crate.
1086///
1087/// The start time (in clock ticks since boot) is stable for the lifetime
1088/// of a process and unique enough to detect PID reuse.
1089///
1090/// Returns `None` if the process doesn't exist or can't be read.
1091fn read_process_start_time(pid: u32) -> Option<u64> {
1092    let process = procfs::process::Process::new(pid as i32).ok()?;
1093    let stat = process.stat().ok()?;
1094    Some(stat.starttime)
1095}
1096
1097impl Default for SessionInfrastructure {
1098    fn default() -> Self {
1099        Self::new()
1100    }
1101}
1102
1103// ============================================================================
1104// Application Layer DTO
1105// ============================================================================
1106
1107/// Read-only view of a session for TUI display.
1108///
1109/// Immutable snapshot created from SessionDomain.
1110/// Implements Clone for easy distribution to multiple UI components.
1111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1112pub struct SessionView {
1113    /// Session identifier
1114    pub id: SessionId,
1115
1116    /// Short ID for display (first 8 chars)
1117    pub id_short: String,
1118
1119    /// Agent type label
1120    pub agent_type: String,
1121
1122    /// Coding-agent harness short tag (`"claude"`, `"pi"`, `"?"`).
1123    /// Drives the vendor badge in the TUI.
1124    #[serde(default)]
1125    pub harness: String,
1126
1127    /// Model display name
1128    pub model: String,
1129
1130    /// Current status (3-state model)
1131    pub status: SessionStatus,
1132
1133    /// Status label for display
1134    pub status_label: String,
1135
1136    /// Activity detail (tool name or context)
1137    pub activity_detail: Option<String>,
1138
1139    /// Whether this status should blink
1140    pub should_blink: bool,
1141
1142    /// Status icon
1143    pub status_icon: String,
1144
1145    /// Context usage percentage
1146    pub context_percentage: f64,
1147
1148    /// Context usage formatted string
1149    pub context_display: String,
1150
1151    /// Whether context is in warning state
1152    pub context_warning: bool,
1153
1154    /// Whether context is in critical state
1155    pub context_critical: bool,
1156
1157    /// Cost formatted string
1158    pub cost_display: String,
1159
1160    /// Cost in USD (for sorting)
1161    pub cost_usd: f64,
1162
1163    /// Duration formatted string
1164    pub duration_display: String,
1165
1166    /// Duration in seconds (for sorting)
1167    pub duration_seconds: f64,
1168
1169    /// Lines changed formatted string
1170    pub lines_display: String,
1171
1172    /// Working directory (shortened for display)
1173    pub working_directory: Option<String>,
1174
1175    /// Whether session needs attention (permission wait, high context)
1176    pub needs_attention: bool,
1177
1178    /// Time since last activity (formatted)
1179    pub last_activity_display: String,
1180
1181    /// Session age (formatted)
1182    pub age_display: String,
1183
1184    /// Session start time (ISO 8601)
1185    pub started_at: String,
1186
1187    /// Last activity time (ISO 8601)
1188    pub last_activity: String,
1189
1190    /// Tmux pane ID (e.g., "%5") if session is running in tmux
1191    pub tmux_pane: Option<String>,
1192
1193    /// Git project root (for grouping in tree view)
1194    #[serde(skip_serializing_if = "Option::is_none")]
1195    pub project_root: Option<String>,
1196
1197    /// Git worktree path
1198    #[serde(skip_serializing_if = "Option::is_none")]
1199    pub worktree_path: Option<String>,
1200
1201    /// Git branch name for this worktree
1202    #[serde(skip_serializing_if = "Option::is_none")]
1203    pub worktree_branch: Option<String>,
1204
1205    /// Parent session ID (if this is a subagent)
1206    #[serde(skip_serializing_if = "Option::is_none")]
1207    pub parent_session_id: Option<SessionId>,
1208
1209    /// Child subagent session IDs
1210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1211    pub child_session_ids: Vec<SessionId>,
1212
1213    /// First user prompt (for preview summary)
1214    #[serde(skip_serializing_if = "Option::is_none")]
1215    pub first_prompt: Option<String>,
1216}
1217
1218impl SessionView {
1219    /// Creates a SessionView from a SessionDomain.
1220    pub fn from_domain(session: &SessionDomain) -> Self {
1221        let now = Utc::now();
1222        let since_activity = now.signed_duration_since(session.last_activity);
1223        let age = now.signed_duration_since(session.started_at);
1224
1225        Self {
1226            id: session.id.clone(),
1227            id_short: session.id.short().to_string(),
1228            agent_type: session.agent_type.short_name().to_string(),
1229            harness: session.harness.short_tag().to_string(),
1230            model: if session.model.is_unknown() {
1231                session
1232                    .model_display_override
1233                    .clone()
1234                    .unwrap_or_else(|| session.model.display_name().to_string())
1235            } else {
1236                session.model.display_name().to_string()
1237            },
1238            status: session.status,
1239            status_label: session.status.label().to_string(),
1240            activity_detail: session
1241                .current_activity
1242                .as_ref()
1243                .map(|a| a.display().into_owned()),
1244            should_blink: session.status.should_blink(),
1245            status_icon: session.status.icon().to_string(),
1246            context_percentage: session.context.usage_percentage(),
1247            context_display: session.context.format(),
1248            context_warning: session.context.is_warning(),
1249            context_critical: session.context.is_critical(),
1250            cost_display: session.cost.format(),
1251            cost_usd: session.cost.as_usd(),
1252            duration_display: session.duration.format(),
1253            duration_seconds: session.duration.total_seconds(),
1254            lines_display: session.lines_changed.format(),
1255            working_directory: session.working_directory.clone().map(|p| {
1256                // Shorten path for display
1257                if p.len() > 30 {
1258                    format!("...{}", &p[p.len().saturating_sub(27)..])
1259                } else {
1260                    p
1261                }
1262            }),
1263            needs_attention: session.status.needs_attention() || session.needs_context_attention(),
1264            last_activity_display: format_duration(since_activity),
1265            age_display: format_duration(age),
1266            started_at: session.started_at.to_rfc3339(),
1267            last_activity: session.last_activity.to_rfc3339(),
1268            tmux_pane: session.tmux_pane.clone(),
1269            project_root: session.project_root.clone(),
1270            worktree_path: session.worktree_path.clone(),
1271            worktree_branch: session.worktree_branch.clone(),
1272            parent_session_id: session.parent_session_id.clone(),
1273            child_session_ids: session.child_session_ids.clone(),
1274            first_prompt: session.first_prompt.clone(),
1275        }
1276    }
1277}
1278
1279impl From<&SessionDomain> for SessionView {
1280    fn from(session: &SessionDomain) -> Self {
1281        Self::from_domain(session)
1282    }
1283}
1284
1285/// Formats a duration for human-readable display.
1286fn format_duration(duration: chrono::Duration) -> String {
1287    let secs = duration.num_seconds();
1288    if secs < 0 {
1289        return "now".to_string();
1290    }
1291    if secs < 60 {
1292        format!("{secs}s ago")
1293    } else if secs < 3600 {
1294        let mins = secs / 60;
1295        format!("{mins}m ago")
1296    } else if secs < 86400 {
1297        let hours = secs / 3600;
1298        format!("{hours}h ago")
1299    } else {
1300        let days = secs / 86400;
1301        format!("{days}d ago")
1302    }
1303}
1304
1305#[cfg(test)]
1306mod tests {
1307    use super::*;
1308    use crate::tool::Tool;
1309
1310    /// Creates a test session with default values.
1311    fn create_test_session(id: &str) -> SessionDomain {
1312        SessionDomain::new(SessionId::new(id), AgentType::GeneralPurpose, Model::Opus45)
1313    }
1314
1315    #[test]
1316    fn test_session_id_short() {
1317        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1318        assert_eq!(id.short(), "8e11bfb5");
1319    }
1320
1321    #[test]
1322    fn test_session_id_short_short_id() {
1323        let id = SessionId::new("abc");
1324        assert_eq!(id.short(), "abc");
1325    }
1326
1327    #[test]
1328    fn test_session_status_display() {
1329        let status = SessionStatus::Working;
1330        assert_eq!(format!("{status}"), "Working");
1331    }
1332
1333    #[test]
1334    fn test_session_domain_creation() {
1335        let session = SessionDomain::new(
1336            SessionId::new("test-123"),
1337            AgentType::GeneralPurpose,
1338            Model::Opus45,
1339        );
1340        assert_eq!(session.id.as_str(), "test-123");
1341        assert_eq!(session.model, Model::Opus45);
1342        assert!(session.cost.is_zero());
1343    }
1344
1345    #[test]
1346    fn test_session_view_from_domain() {
1347        let session = SessionDomain::new(
1348            SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731"),
1349            AgentType::Explore,
1350            Model::Sonnet4,
1351        );
1352        let view = SessionView::from_domain(&session);
1353
1354        assert_eq!(view.id_short, "8e11bfb5");
1355        assert_eq!(view.agent_type, "explore");
1356        assert_eq!(view.model, "Sonnet 4");
1357    }
1358
1359    #[test]
1360    fn test_session_view_unknown_model_with_override() {
1361        let mut session = SessionDomain::new(
1362            SessionId::new("test-override"),
1363            AgentType::GeneralPurpose,
1364            Model::Unknown,
1365        );
1366        session.model_display_override = Some("GPT-4o".to_string());
1367
1368        let view = SessionView::from_domain(&session);
1369        assert_eq!(view.model, "GPT-4o");
1370    }
1371
1372    #[test]
1373    fn test_session_view_unknown_model_without_override() {
1374        let session = SessionDomain::new(
1375            SessionId::new("test-no-override"),
1376            AgentType::GeneralPurpose,
1377            Model::Unknown,
1378        );
1379
1380        let view = SessionView::from_domain(&session);
1381        assert_eq!(view.model, "Unknown");
1382    }
1383
1384    #[test]
1385    fn test_session_view_known_model_ignores_override() {
1386        let mut session = SessionDomain::new(
1387            SessionId::new("test-known"),
1388            AgentType::GeneralPurpose,
1389            Model::Opus46,
1390        );
1391        // Even if override is set, known models use their display_name
1392        session.model_display_override = Some("something else".to_string());
1393
1394        let view = SessionView::from_domain(&session);
1395        assert_eq!(view.model, "Opus 4.6");
1396    }
1397
1398    #[test]
1399    fn test_lines_changed() {
1400        let lines = LinesChanged::new(150, 30);
1401        assert_eq!(lines.net(), 120);
1402        assert_eq!(lines.churn(), 180);
1403        assert_eq!(lines.format(), "+150 -30");
1404        assert_eq!(lines.format_net(), "+120");
1405    }
1406
1407    #[test]
1408    fn test_session_duration_formatting() {
1409        assert_eq!(SessionDuration::from_total_ms(35_000).format(), "35s");
1410        assert_eq!(SessionDuration::from_total_ms(135_000).format(), "2m 15s");
1411        assert_eq!(SessionDuration::from_total_ms(5_400_000).format(), "1h 30m");
1412    }
1413
1414    #[test]
1415    fn test_session_id_pending_from_pid() {
1416        let id = SessionId::pending_from_pid(12345);
1417        assert_eq!(id.as_str(), "pending-12345");
1418        assert!(id.is_pending());
1419        assert_eq!(id.pending_pid(), Some(12345));
1420    }
1421
1422    #[test]
1423    fn test_session_id_is_pending_true() {
1424        let id = SessionId::new("pending-99999");
1425        assert!(id.is_pending());
1426    }
1427
1428    #[test]
1429    fn test_session_id_is_pending_false() {
1430        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1431        assert!(!id.is_pending());
1432    }
1433
1434    #[test]
1435    fn test_session_id_pending_pid_returns_none_for_regular_id() {
1436        let id = SessionId::new("8e11bfb5-7dc2-432b-9206-928fa5c35731");
1437        assert_eq!(id.pending_pid(), None);
1438    }
1439
1440    #[test]
1441    fn test_session_id_pending_pid_returns_none_for_invalid_pid() {
1442        let id = SessionId::new("pending-not-a-number");
1443        assert_eq!(id.pending_pid(), None);
1444    }
1445
1446    #[test]
1447    fn lifecycle_provider_model_change_known_claude_id() {
1448        // A pi session targeting a Claude model should map onto the
1449        // existing Model variant; no override needed.
1450        let mut session = create_test_session("test-pmc-known");
1451        session.model = Model::Unknown;
1452        session.model_display_override = Some("stale".to_string());
1453
1454        session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1455            provider: Some("anthropic".to_string()),
1456            model: Some("claude-sonnet-4-5-20250929".to_string()),
1457        });
1458
1459        assert_eq!(session.model, Model::Sonnet45);
1460        assert!(
1461            session.model_display_override.is_none(),
1462            "override must be cleared when the id maps to a known model"
1463        );
1464    }
1465
1466    #[test]
1467    fn lifecycle_provider_model_change_unknown_id() {
1468        // A pi session pointed at a non-Claude provider should land
1469        // as Unknown with the raw id surfaced via the override field
1470        // so the TUI shows something meaningful instead of "Unknown".
1471        let mut session = create_test_session("test-pmc-unknown");
1472        session.model = Model::Unknown;
1473        session.model_display_override = None;
1474
1475        session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1476            provider: Some("openai".to_string()),
1477            model: Some("gpt-4o".to_string()),
1478        });
1479
1480        assert_eq!(session.model, Model::Unknown);
1481        assert_eq!(session.model_display_override.as_deref(), Some("gpt-4o"));
1482    }
1483
1484    #[test]
1485    fn lifecycle_provider_model_change_no_model_is_noop() {
1486        let mut session = create_test_session("test-pmc-none");
1487        session.model = Model::Sonnet4;
1488        session.model_display_override = None;
1489
1490        session.apply_lifecycle_event(&LifecycleEvent::ProviderModelChange {
1491            provider: Some("anthropic".to_string()),
1492            model: None,
1493        });
1494
1495        assert_eq!(session.model, Model::Sonnet4);
1496        assert!(session.model_display_override.is_none());
1497    }
1498
1499    #[test]
1500    fn lifecycle_needs_input_for_interactive_tool() {
1501        let mut session = create_test_session("test-interactive");
1502
1503        session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1504            reason: NeedsInputReason::InteractiveTool {
1505                tool: Tool::AskUserQuestion,
1506            },
1507        });
1508        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1509        assert_eq!(
1510            session
1511                .current_activity
1512                .as_ref()
1513                .map(|a| a.display())
1514                .as_deref(),
1515            Some("AskUserQuestion")
1516        );
1517
1518        session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
1519            name: Tool::AskUserQuestion,
1520            tool_use_id: None,
1521            is_error: false,
1522        });
1523        assert_eq!(session.status, SessionStatus::Working);
1524    }
1525
1526    #[test]
1527    fn lifecycle_needs_input_for_enter_plan_mode() {
1528        let mut session = create_test_session("test-plan");
1529
1530        session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1531            reason: NeedsInputReason::InteractiveTool {
1532                tool: Tool::EnterPlanMode,
1533            },
1534        });
1535        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1536        assert_eq!(
1537            session
1538                .current_activity
1539                .as_ref()
1540                .map(|a| a.display())
1541                .as_deref(),
1542            Some("EnterPlanMode")
1543        );
1544    }
1545
1546    #[test]
1547    fn lifecycle_needs_input_notification_uses_label_when_present() {
1548        // The `label` plumbing exists so the TUI shows *what* permission
1549        // is being asked (the dialog title forwarded by pi-atm's
1550        // `ctx.ui.select` wrapper) instead of a generic kind string.
1551        let mut session = create_test_session("test-label");
1552
1553        session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1554            reason: NeedsInputReason::Notification {
1555                kind: NotificationKind::PermissionPrompt,
1556                label: Some("Allow `rm -rf /tmp/cache`?".into()),
1557            },
1558        });
1559        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1560        assert_eq!(
1561            session
1562                .current_activity
1563                .as_ref()
1564                .map(|a| a.display())
1565                .as_deref(),
1566            Some("Allow `rm -rf /tmp/cache`?")
1567        );
1568    }
1569
1570    #[test]
1571    fn lifecycle_needs_input_notification_falls_back_to_kind_when_label_absent() {
1572        // Claude `Notification(permission_prompt)` events don't carry a
1573        // per-prompt label — only a kind tag. Verify the fallback
1574        // rendering still resolves to the kind-derived string.
1575        let mut session = create_test_session("test-no-label");
1576
1577        session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1578            reason: NeedsInputReason::Notification {
1579                kind: NotificationKind::PermissionPrompt,
1580                label: None,
1581            },
1582        });
1583        assert_eq!(session.status, SessionStatus::AttentionNeeded);
1584        assert_eq!(
1585            session
1586                .current_activity
1587                .as_ref()
1588                .map(|a| a.display())
1589                .as_deref(),
1590            Some("Permission")
1591        );
1592
1593        session.apply_lifecycle_event(&LifecycleEvent::NeedsInput {
1594            reason: NeedsInputReason::Notification {
1595                kind: NotificationKind::ElicitationDialog,
1596                label: None,
1597            },
1598        });
1599        assert_eq!(
1600            session
1601                .current_activity
1602                .as_ref()
1603                .map(|a| a.display())
1604                .as_deref(),
1605            Some("MCP Input")
1606        );
1607    }
1608
1609    #[test]
1610    fn lifecycle_tool_call_start_for_standard_tool() {
1611        let mut session = create_test_session("test-standard");
1612
1613        session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
1614            name: Tool::Bash,
1615            tool_use_id: None,
1616            input: None,
1617        });
1618        assert_eq!(session.status, SessionStatus::Working);
1619        assert_eq!(
1620            session
1621                .current_activity
1622                .as_ref()
1623                .map(|a| a.display())
1624                .as_deref(),
1625            Some("Bash")
1626        );
1627
1628        session.apply_lifecycle_event(&LifecycleEvent::ToolCallEnd {
1629            name: Tool::Bash,
1630            tool_use_id: None,
1631            is_error: false,
1632        });
1633        assert_eq!(session.status, SessionStatus::Working);
1634    }
1635
1636    #[test]
1637    fn lifecycle_unknown_tool_lands_in_other_and_keeps_name() {
1638        // The empty/unknown case used to be "standard tool with empty name".
1639        // After typing, the same input becomes Tool::Other("") — the session
1640        // still treats it as a working tool call without crashing on missing data.
1641        let mut session = create_test_session("test-other");
1642
1643        session.apply_lifecycle_event(&LifecycleEvent::ToolCallStart {
1644            name: Tool::Other("custom_pi_tool".into()),
1645            tool_use_id: None,
1646            input: None,
1647        });
1648        assert_eq!(session.status, SessionStatus::Working);
1649        assert_eq!(
1650            session
1651                .current_activity
1652                .as_ref()
1653                .map(|a| a.display())
1654                .as_deref(),
1655            Some("custom_pi_tool")
1656        );
1657    }
1658
1659    #[test]
1660    fn test_activity_detail_creation() {
1661        let detail = ActivityDetail::new("Bash");
1662        assert_eq!(detail.tool_name.as_deref(), Some("Bash"));
1663        assert!(detail.started_at <= Utc::now());
1664        assert!(detail.context.is_none());
1665    }
1666
1667    #[test]
1668    fn test_activity_detail_with_context() {
1669        let detail = ActivityDetail::with_context("Compacting");
1670        assert!(detail.tool_name.is_none());
1671        assert_eq!(detail.context.as_deref(), Some("Compacting"));
1672    }
1673
1674    #[test]
1675    fn test_activity_detail_display() {
1676        let detail = ActivityDetail::new("Read");
1677        assert_eq!(detail.display(), "Read");
1678
1679        let context_detail = ActivityDetail::with_context("Setup");
1680        assert_eq!(context_detail.display(), "Setup");
1681    }
1682
1683    #[test]
1684    fn test_new_session_status_variants() {
1685        // All three states should exist
1686        let idle = SessionStatus::Idle;
1687        let working = SessionStatus::Working;
1688        let attention = SessionStatus::AttentionNeeded;
1689
1690        assert_eq!(idle.label(), "idle");
1691        assert_eq!(working.label(), "working");
1692        assert_eq!(attention.label(), "needs input");
1693    }
1694
1695    #[test]
1696    fn test_session_status_should_blink() {
1697        assert!(!SessionStatus::Idle.should_blink());
1698        assert!(!SessionStatus::Working.should_blink());
1699        assert!(SessionStatus::AttentionNeeded.should_blink());
1700    }
1701
1702    #[test]
1703    fn test_session_status_icons() {
1704        assert_eq!(SessionStatus::Idle.icon(), "-");
1705        assert_eq!(SessionStatus::Working.icon(), ">");
1706        assert_eq!(SessionStatus::AttentionNeeded.icon(), "!");
1707    }
1708
1709    #[test]
1710    fn test_session_domain_new_fields_default() {
1711        let session = create_test_session("test-defaults");
1712        assert!(session.project_root.is_none());
1713        assert!(session.worktree_path.is_none());
1714        assert!(session.worktree_branch.is_none());
1715        assert!(session.parent_session_id.is_none());
1716        assert!(session.child_session_ids.is_empty());
1717    }
1718
1719    #[test]
1720    fn test_session_view_includes_new_fields() {
1721        let mut session = create_test_session("test-view-fields");
1722        session.project_root = Some("/home/user/project".to_string());
1723        session.worktree_path = Some("/home/user/worktree".to_string());
1724        session.worktree_branch = Some("feature-x".to_string());
1725        session.parent_session_id = Some(SessionId::new("parent-123"));
1726        session.child_session_ids = vec![SessionId::new("child-1"), SessionId::new("child-2")];
1727
1728        let view = SessionView::from_domain(&session);
1729
1730        assert_eq!(view.project_root, Some("/home/user/project".to_string()));
1731        assert_eq!(view.worktree_path, Some("/home/user/worktree".to_string()));
1732        assert_eq!(view.worktree_branch, Some("feature-x".to_string()));
1733        assert_eq!(view.parent_session_id, Some(SessionId::new("parent-123")));
1734        assert_eq!(view.child_session_ids.len(), 2);
1735        assert_eq!(view.child_session_ids[0].as_str(), "child-1");
1736        assert_eq!(view.child_session_ids[1].as_str(), "child-2");
1737    }
1738
1739    // ========================================================================
1740    // update_from_status_line cwd change detection
1741    // ========================================================================
1742
1743    fn make_status_data(cwd: Option<&str>) -> StatusLineData {
1744        StatusLineData {
1745            session_id: "test".to_string(),
1746            model_id: "claude-sonnet-4-20250514".to_string(),
1747            model_display_name: None,
1748            cost_usd: 0.10,
1749            total_duration_ms: 1000,
1750            api_duration_ms: 500,
1751            lines_added: 10,
1752            lines_removed: 5,
1753            total_input_tokens: 1000,
1754            total_output_tokens: 500,
1755            context_window_size: 200_000,
1756            current_input_tokens: 800,
1757            current_output_tokens: 400,
1758            cache_creation_tokens: 0,
1759            cache_read_tokens: 0,
1760            cwd: cwd.map(|s| s.to_string()),
1761            version: None,
1762        }
1763    }
1764
1765    #[test]
1766    fn test_update_from_status_line_cwd_changed() {
1767        let mut session = SessionDomain::new(
1768            SessionId::new("test"),
1769            AgentType::GeneralPurpose,
1770            Model::Sonnet4,
1771        );
1772        session.working_directory = Some("/home/user/repo-a".to_string());
1773
1774        let data = make_status_data(Some("/home/user/repo-b"));
1775        let changed = session.update_from_status_line(&data);
1776
1777        assert!(changed, "should return true when cwd changes");
1778        assert_eq!(
1779            session.working_directory.as_deref(),
1780            Some("/home/user/repo-b"),
1781            "working_directory should be updated"
1782        );
1783    }
1784
1785    #[test]
1786    fn test_update_from_status_line_cwd_same() {
1787        let mut session = SessionDomain::new(
1788            SessionId::new("test"),
1789            AgentType::GeneralPurpose,
1790            Model::Sonnet4,
1791        );
1792        session.working_directory = Some("/home/user/repo".to_string());
1793
1794        let data = make_status_data(Some("/home/user/repo"));
1795        let changed = session.update_from_status_line(&data);
1796
1797        assert!(!changed, "should return false when cwd is the same");
1798    }
1799
1800    #[test]
1801    fn test_update_from_status_line_cwd_none_to_some() {
1802        let mut session = SessionDomain::new(
1803            SessionId::new("test"),
1804            AgentType::GeneralPurpose,
1805            Model::Sonnet4,
1806        );
1807        // working_directory starts as None
1808
1809        let data = make_status_data(Some("/home/user/repo"));
1810        let changed = session.update_from_status_line(&data);
1811
1812        assert!(
1813            changed,
1814            "should return true when cwd goes from None to Some"
1815        );
1816        assert_eq!(
1817            session.working_directory.as_deref(),
1818            Some("/home/user/repo")
1819        );
1820    }
1821
1822    #[test]
1823    fn test_update_from_status_line_cwd_some_to_none() {
1824        let mut session = SessionDomain::new(
1825            SessionId::new("test"),
1826            AgentType::GeneralPurpose,
1827            Model::Sonnet4,
1828        );
1829        session.working_directory = Some("/home/user/repo".to_string());
1830
1831        let data = make_status_data(None);
1832        let changed = session.update_from_status_line(&data);
1833
1834        assert!(
1835            !changed,
1836            "should return false when incoming cwd is None (partial update)"
1837        );
1838        assert_eq!(
1839            session.working_directory.as_deref(),
1840            Some("/home/user/repo"),
1841            "should preserve existing cwd when incoming is None"
1842        );
1843    }
1844}