Skip to main content

batuta/agent/
status_line.rs

1//! REPL status line renderer (Claude-Code parity).
2//!
3//! PMAT-CODE-STATUS-LINE-001: Claude Code renders a one-line status
4//! strip at the bottom of the REPL showing model name, permission
5//! mode, session cost, git branch, and cwd-relative directory. This
6//! module ships the data struct + pure render function so TUI/REPL
7//! call sites don't duplicate the formatting rules.
8//!
9//! # Example
10//!
11//! ```rust
12//! use aprender_orchestrate::agent::status_line::StatusLine;
13//!
14//! let line = StatusLine {
15//!     model: "opus-4-7".into(),
16//!     mode: "bypassPermissions".into(),
17//!     cost_usd: 0.0234,
18//!     branch: Some("main".into()),
19//!     cwd_short: Some("~/src/aprender".into()),
20//! };
21//! let rendered = line.render();
22//! assert!(rendered.contains("opus-4-7"));
23//! assert!(rendered.contains("bypassPermissions"));
24//! assert!(rendered.contains("$0.02"));
25//! assert!(rendered.contains("main"));
26//! ```
27
28use std::path::Path;
29
30use super::permission::PermissionMode;
31
32/// Data captured by the REPL and rendered as a one-line status strip.
33///
34/// All fields are owned strings so the renderer is pure and
35/// trivially testable without borrowing from the live session.
36#[derive(Debug, Clone, PartialEq)]
37pub struct StatusLine {
38    /// Model identifier (e.g. `opus-4-7`, `qwen2.5-coder-7b`).
39    pub model: String,
40    /// Permission-mode canonical identifier
41    /// (`default`/`plan`/`acceptEdits`/`bypassPermissions`).
42    pub mode: String,
43    /// Session cost in USD. Formatted to 2 decimal places in the
44    /// output.
45    pub cost_usd: f64,
46    /// Git branch, if the REPL is inside a repo.
47    pub branch: Option<String>,
48    /// Home-relative cwd display (e.g. `~/src/aprender`).
49    pub cwd_short: Option<String>,
50}
51
52impl StatusLine {
53    /// Render as a single-line string using the Claude-Code column
54    /// order: `model | [mode] | $cost | branch | cwd`.
55    ///
56    /// Missing optional fields are elided (no empty cells).
57    pub fn render(&self) -> String {
58        let mut parts: Vec<String> = Vec::with_capacity(5);
59        parts.push(self.model.clone());
60        parts.push(format!("[{}]", self.mode));
61        parts.push(format!("${:.2}", self.cost_usd));
62        if let Some(branch) = self.branch.as_deref() {
63            parts.push(branch.to_string());
64        }
65        if let Some(cwd) = self.cwd_short.as_deref() {
66            parts.push(cwd.to_string());
67        }
68        parts.join(" | ")
69    }
70
71    /// Build a [`StatusLine`] from the live session primitives.
72    ///
73    /// Pure — caller passes in already-resolved values so this is
74    /// trivially testable without touching git or the filesystem.
75    pub fn build(
76        model: impl Into<String>,
77        mode: PermissionMode,
78        cost_usd: f64,
79        branch: Option<String>,
80        cwd_short: Option<String>,
81    ) -> Self {
82        Self { model: model.into(), mode: mode.to_string(), cost_usd, branch, cwd_short }
83    }
84}
85
86/// Collapse `$HOME` prefix in `path` to `~/` for display.
87///
88/// Returns `path.to_string_lossy()` when no home prefix matches.
89pub fn short_cwd(path: &Path, home: Option<&Path>) -> String {
90    if let Some(home) = home {
91        if let Ok(stripped) = path.strip_prefix(home) {
92            if stripped.as_os_str().is_empty() {
93                return "~".to_string();
94            }
95            return format!("~/{}", stripped.display());
96        }
97    }
98    path.display().to_string()
99}
100
101#[cfg(test)]
102mod tests;