Skip to main content

teamctl_ui/
status_bar.rs

1//! Bottom status bar — operator-orientation strip beneath the existing
2//! keybindings statusline. Two slots:
3//!
4//! - **Left:** the team root (`app.team.root`) — what `.team/` directory
5//!   the TUI was launched against. Smart-truncated to fit (`~/`-prefixed
6//!   when under HOME, middle-ellipsis when over the slot's budget).
7//! - **Right:** live system CPU% + RAM%, refreshed on the existing
8//!   1-second App refresh tick (see `app::REFRESH_INTERVAL`). No
9//!   background thread — sysinfo's per-tick refresh is sub-millisecond.
10//!
11//! - **Center:** the focused agent's claude rate-limit window when
12//!   active, formatted as `limit 5m 12s` (T-212). On by default (#431);
13//!   operators opt out with `TEAMCTL_UI_RATE_LIMIT_INDICATOR=0`. Hides
14//!   when the focused agent has no active window; swaps with focus.
15//!
16//! Truncation priority on narrow terminals: **path > per-agent
17//! center > CPU/RAM** (T-209 done-when, extended by T-212). Operators
18//! who can't see WHERE the team is running lose more than operators
19//! who can't see the per-agent indicator, who in turn lose more than
20//! operators who can't see live CPU%. Matches the statusline
21//! module's right-anchor-elides-first pattern from `statusline.rs`.
22
23use std::path::Path;
24
25use ratatui::buffer::Buffer;
26use ratatui::layout::Rect;
27use ratatui::style::{Modifier, Style};
28use ratatui::widgets::Widget;
29
30use crate::app::App;
31use crate::data::format_rate_limit_window;
32
33/// Render the bottom status bar into the supplied `area`. Mirrors the
34/// `statusline::draw` entry-point shape so the call site is uniform.
35pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
36    StatusBar { app }.render(area, f.buffer_mut());
37}
38
39pub struct StatusBar<'a> {
40    pub app: &'a App,
41}
42
43impl Widget for StatusBar<'_> {
44    fn render(self, area: Rect, buf: &mut Buffer) {
45        if area.width == 0 || area.height == 0 {
46            return;
47        }
48        let muted = self.app.capabilities.muted();
49        // System metrics are rendered as a single short string —
50        // measured once and right-aligned so they hug the trailing
51        // edge of the bar regardless of the path's length.
52        let metrics = system_metrics_string(&self.app.sysinfo);
53
54        // Truncation priority: path wins. Compute the path slot first
55        // with the FULL width, then reserve metrics-width on the right
56        // only if there's enough headroom after the path is rendered.
57        let area_w = area.width as usize;
58        let metrics_w = metrics.chars().count();
59        let path_str = display_path(&self.app.team.root);
60
61        // Reserve at least one space of gutter between path and
62        // metrics if metrics will render. If the terminal is too
63        // narrow for both, metrics elide entirely.
64        let gutter = 1usize;
65        let metrics_will_render = area_w >= path_min_width() + metrics_w + gutter;
66
67        let path_budget = if metrics_will_render {
68            area_w.saturating_sub(metrics_w + gutter)
69        } else {
70            area_w
71        };
72        let path_rendered = truncate_path_middle(&path_str, path_budget);
73
74        // Render path at column 0.
75        buf.set_string(area.x, area.y, &path_rendered, Style::default().fg(muted));
76
77        // T-212 / #431: per-agent rate-limit indicator in the center
78        // slot. Gated on the `rate_limit_indicator_enabled` flag, which
79        // is on by default; operators opt out with
80        // `TEAMCTL_UI_RATE_LIMIT_INDICATOR=0`. When the flag is off, the
81        // slot stays blank regardless of agent state; path + metrics
82        // layout is unchanged from the T-209 baseline.
83        //
84        // When enabled, renders ONLY when the focused agent has an
85        // active rate-limit window (`format_rate_limit_window`
86        // returns `Some` — past or unset windows yield `None`).
87        // Truncation priority per the contract with otis on T-209:
88        // path > per-agent > metrics. We honor it by measure-and-fit
89        // — the slot renders only if there's room between the path's
90        // rendered right edge (+gutter) and the metrics' x position
91        // (-gutter). When the terminal is too narrow, the indicator
92        // simply doesn't render; path and metrics keep their slots.
93        let center_text: Option<String> = if self.app.rate_limit_indicator_enabled {
94            self.app
95                .selected_agent
96                .and_then(|i| self.app.team.agents.get(i))
97                .and_then(|a| {
98                    let now_unix = std::time::SystemTime::now()
99                        .duration_since(std::time::UNIX_EPOCH)
100                        .map(|d| d.as_secs_f64())
101                        .unwrap_or(0.0);
102                    format_rate_limit_window(a.rate_limit_resets_at, now_unix)
103                })
104                .map(|w| format!("limit {w}"))
105        } else {
106            None
107        };
108
109        let metrics_x_or_end = if metrics_will_render {
110            area_w.saturating_sub(metrics_w)
111        } else {
112            area_w
113        };
114        if let Some(text) = center_text {
115            let text_w = text.chars().count();
116            let path_actual_w = path_rendered.chars().count();
117            let center_left_bound = path_actual_w + gutter;
118            let center_right_bound = metrics_x_or_end.saturating_sub(gutter);
119            if center_right_bound > center_left_bound
120                && text_w <= center_right_bound - center_left_bound
121            {
122                let avail = center_right_bound - center_left_bound;
123                let pad = (avail - text_w) / 2;
124                let center_x = area.x + (center_left_bound + pad) as u16;
125                buf.set_string(center_x, area.y, &text, Style::default().fg(muted));
126            }
127        }
128
129        if metrics_will_render {
130            let metrics_x = area.x + (area_w as u16 - metrics_w as u16);
131            buf.set_string(
132                metrics_x,
133                area.y,
134                &metrics,
135                Style::default().fg(muted).add_modifier(Modifier::DIM),
136            );
137        }
138    }
139}
140
141/// Minimum number of columns we ever spend on the path before forcing
142/// metrics to elide. Below this, the path itself starts truncating
143/// (head/tail ellipsis) but it stays visible — losing path entirely
144/// would be worse than losing live metrics.
145fn path_min_width() -> usize {
146    // Leaves room for at least `~/.../<basename>` shape (~12 chars).
147    12
148}
149
150/// Render `path` as the operator-friendly string the status bar shows
151/// when there's room: HOME-relative when applicable, full otherwise.
152/// Truncation to fit a budget happens separately via
153/// [`truncate_path_middle`].
154fn display_path(path: &Path) -> String {
155    if let Some(home) = dirs::home_dir() {
156        if let Ok(rest) = path.strip_prefix(&home) {
157            // Bare `~` with no trailing slash if the team root IS home.
158            if rest.as_os_str().is_empty() {
159                return "~".to_string();
160            }
161            return format!("~/{}", rest.display());
162        }
163    }
164    path.display().to_string()
165}
166
167/// Smart-truncate a path string to at most `max_width` chars. Strategy:
168///
169/// - If the string fits, return it as-is.
170/// - If it doesn't, keep the leading prefix (so HOME-relative `~/...`
171///   stays recognizable) and the trailing basename (so the operator
172///   sees what they launched against), with a middle `…` separator.
173/// - If even basename-only doesn't fit, hard-clip from the left and
174///   prefix with `…`.
175///
176/// Counts USVs (`chars()`), not bytes — paths can contain multi-byte
177/// chars. Ratatui's `set_string` measures display width, so this gives
178/// a tight upper bound but is an OK approximation for ASCII paths.
179fn truncate_path_middle(path: &str, max_width: usize) -> String {
180    let total = path.chars().count();
181    if total <= max_width {
182        return path.to_string();
183    }
184    if max_width <= 1 {
185        return "…".to_string();
186    }
187    // Head/tail split — keep the basename intact when possible.
188    // Reserve 1 char for the middle ellipsis.
189    let budget = max_width - 1;
190    // Find the basename (last `/`-segment) length.
191    let basename_len = path
192        .rsplit('/')
193        .next()
194        .map(|s| s.chars().count())
195        .unwrap_or(0);
196    if basename_len + 4 < budget {
197        // We have room for `<head>…<basename>`; spend the budget as
198        // head = budget - basename_len, tail = basename_len.
199        let head_len = budget - basename_len;
200        let head: String = path.chars().take(head_len).collect();
201        let tail: String = path
202            .chars()
203            .rev()
204            .take(basename_len)
205            .collect::<Vec<_>>()
206            .into_iter()
207            .rev()
208            .collect();
209        return format!("{head}…{tail}");
210    }
211    // Basename alone overflows — hard-clip with leading ellipsis.
212    let tail: String = path
213        .chars()
214        .rev()
215        .take(budget)
216        .collect::<Vec<_>>()
217        .into_iter()
218        .rev()
219        .collect();
220    format!("…{tail}")
221}
222
223/// Compose the metrics string from a refreshed [`sysinfo::System`].
224/// Shape: `CPU 12% · RAM 4.2/16 GB`. Compact enough to fit alongside
225/// the path on common terminal widths (≥ 80 cols). The dot separator
226/// matches the statusline module's `·`-joined hint convention.
227fn system_metrics_string(sys: &sysinfo::System) -> String {
228    let cpu = global_cpu_percent(sys);
229    let (used_gb, total_gb) = ram_used_total_gb(sys);
230    format!("CPU {cpu}% · RAM {used_gb:.1}/{total_gb:.0} GB")
231}
232
233fn global_cpu_percent(sys: &sysinfo::System) -> u8 {
234    // `global_cpu_usage` is a 0..100 f32 representing the aggregate
235    // across all cores. Round to the nearest u8 — the status bar
236    // doesn't have room for decimal precision and the operator
237    // doesn't need it.
238    sys.global_cpu_usage().round().clamp(0.0, 100.0) as u8
239}
240
241fn ram_used_total_gb(sys: &sysinfo::System) -> (f32, f32) {
242    // sysinfo reports memory in BYTES on 0.32+. Convert to gigabytes
243    // (decimal — `10^9`, matching what most operators see in their
244    // system monitors) for display.
245    const GB: f32 = 1_000_000_000.0;
246    let used = sys.used_memory() as f32 / GB;
247    let total = sys.total_memory() as f32 / GB;
248    (used, total)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn truncate_path_middle_returns_path_unchanged_when_it_fits() {
257        assert_eq!(
258            truncate_path_middle("/home/user/proj", 32),
259            "/home/user/proj"
260        );
261    }
262
263    #[test]
264    fn truncate_path_middle_preserves_basename_with_head_ellipsis() {
265        // Budget tight enough to force truncation but loose enough
266        // for head+tail+ellipsis. Basename `teamctl` stays visible.
267        let truncated = truncate_path_middle("/home/alireza/dev/projects/teamctl", 20);
268        assert!(
269            truncated.ends_with("teamctl"),
270            "basename lost: {truncated:?}"
271        );
272        assert!(truncated.contains('…'), "no ellipsis: {truncated:?}");
273        assert!(truncated.chars().count() <= 20);
274    }
275
276    #[test]
277    fn truncate_path_middle_hard_clips_when_basename_overflows() {
278        // Budget smaller than the basename — fall back to right-anchor
279        // hard-clip with leading ellipsis.
280        let truncated =
281            truncate_path_middle("/home/user/extremely-long-project-directory-name", 12);
282        assert!(
283            truncated.starts_with('…'),
284            "no leading ellipsis: {truncated:?}"
285        );
286        assert!(truncated.chars().count() <= 12);
287    }
288
289    #[test]
290    fn truncate_path_middle_degenerate_widths() {
291        assert_eq!(truncate_path_middle("/long/path", 0), "…");
292        assert_eq!(truncate_path_middle("/long/path", 1), "…");
293    }
294
295    #[test]
296    fn truncate_path_middle_counts_chars_not_bytes() {
297        // Multi-byte char in the path. `é` is 2 bytes in UTF-8 but
298        // one display column for our purposes.
299        let truncated = truncate_path_middle("/home/usér/projet", 13);
300        assert!(truncated.chars().count() <= 13);
301    }
302
303    #[test]
304    fn display_path_collapses_home_prefix() {
305        // We can only test this when HOME is resolvable; if not,
306        // skip (CI runners always set HOME).
307        if let Some(home) = dirs::home_dir() {
308            let under_home = home.join("dev/projects/teamctl/.team");
309            let rendered = display_path(&under_home);
310            assert!(
311                rendered.starts_with("~/"),
312                "expected ~-prefix: {rendered:?}"
313            );
314            assert!(rendered.ends_with("teamctl/.team"));
315        }
316    }
317
318    #[test]
319    fn display_path_returns_full_path_outside_home() {
320        let outside = Path::new("/tmp/teamctl-fixture/.team");
321        let rendered = display_path(outside);
322        assert_eq!(rendered, "/tmp/teamctl-fixture/.team");
323    }
324
325    #[test]
326    fn display_path_handles_path_equal_to_home() {
327        if let Some(home) = dirs::home_dir() {
328            assert_eq!(display_path(&home), "~");
329        }
330    }
331
332    #[test]
333    fn metrics_string_is_compact_and_well_formed() {
334        let mut sys = sysinfo::System::new();
335        sys.refresh_memory();
336        sys.refresh_cpu_usage();
337        let s = system_metrics_string(&sys);
338        assert!(s.starts_with("CPU "), "metrics shape changed: {s:?}");
339        assert!(s.contains(" · RAM "), "separator missing: {s:?}");
340        assert!(s.ends_with(" GB"), "trailing unit missing: {s:?}");
341        // ≤ 30 chars at typical sizes — leaves room for the path.
342        assert!(s.chars().count() < 30, "metrics too wide: {s:?}");
343    }
344
345    #[test]
346    fn path_min_width_is_reasonable() {
347        // Sanity-pin: the minimum reservation should be wide enough
348        // for `~/…/basename` shape (~10-12 chars).
349        assert!(path_min_width() >= 10);
350        assert!(path_min_width() <= 16);
351    }
352}