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;