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}