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