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}