| 1 | //! Footer bar widget displaying mode, status, model, and auxiliary chips. |
| 2 | //! |
| 3 | //! `FooterWidget` is a pure render of a [`FooterProps`] struct: all content |
| 4 | //! (labels, colors, span clusters) is computed once per redraw at a higher |
| 5 | //! level, then `FooterWidget::new(props).render(area, buf)` paints the |
| 6 | //! result. The widget owns no `App` knowledge; this mirrors the layout used |
| 7 | //! by `HeaderWidget` (and Codex's `bottom_pane::footer::Footer`). |
| 8 | |
| 9 | use ratatui::{ |
| 10 | buffer::Buffer, |
| 11 | layout::Rect, |
| 12 | style::{Color, Style}, |
| 13 | text::{Line, Span}, |
| 14 | widgets::{Paragraph, Widget}, |
| 15 | }; |
| 16 | use unicode_width::UnicodeWidthStr; |
| 17 | |
| 18 | use crate::localization::{Locale, MessageId, tr}; |
| 19 | use crate::palette; |
| 20 | use crate::tui::app::{App, AppMode}; |
| 21 | |
| 22 | use super::Renderable; |
| 23 | |
| 24 | /// Pre-computed data the footer needs to render. |
| 25 | /// |
| 26 | /// All fields are owned `String` / `Vec<Span<'static>>` values so the props |
| 27 | /// can be built once per redraw and then handed to a borrow-free widget. |
| 28 | #[derive(Debug, Clone)] |
| 29 | pub struct FooterProps { |
| 30 | /// The current model identifier shown after the mode chip. |
| 31 | pub model: String, |
| 32 | /// `"agent"` / `"yolo"` / `"plan"` — the canonical setting label. |
| 33 | pub mode_label: &'static str, |
| 34 | /// Color used for the mode chip. |
| 35 | pub mode_color: Color, |
| 36 | /// Color used for small separators between chips. |
| 37 | pub text_dim_color: Color, |
| 38 | /// Color used for the model label. |
| 39 | pub text_hint_color: Color, |
| 40 | /// Color used for steady secondary chips such as cost. |
| 41 | pub text_muted_color: Color, |
| 42 | /// Background color for the full footer/status bar row. |
| 43 | pub footer_bg: Color, |
| 44 | /// Status label like `"ready"`, `"thinking ⌫"`, `"working"`. When the |
| 45 | /// label equals `"ready"` the footer hides the status segment entirely. |
| 46 | pub state_label: String, |
| 47 | /// Color used for the status label. |
| 48 | pub state_color: Color, |
| 49 | /// Coherence chip spans (empty when no active intervention). |
| 50 | pub coherence: Vec<Span<'static>>, |
| 51 | /// Sub-agent count chip spans (empty when zero in-flight). |
| 52 | pub agents: Vec<Span<'static>>, |
| 53 | /// Reasoning-replay chip spans (empty when zero / not applicable). |
| 54 | pub reasoning_replay: Vec<Span<'static>>, |
| 55 | /// Cache-hit-rate chip spans (empty when no usage reported). |
| 56 | pub cache: Vec<Span<'static>>, |
| 57 | /// MCP server health chip spans (empty when no MCP servers configured). |
| 58 | /// Populated lazily — see [`footer_mcp_chip`]. (#502) |
| 59 | pub mcp: Vec<Span<'static>>, |
| 60 | /// Cumulative model-work chip spans ("worked 3h 12m"). Sums the |
| 61 | /// elapsed time of completed turns (from `App::cumulative_turn_duration`), |
| 62 | /// **not** wall-clock since launch — an idle TUI shouldn't claim |
| 63 | /// it's been "working." Empty until cumulative turn time crosses |
| 64 | /// 60s. Populated by [`footer_worked_chip`]. (#448) |
| 65 | pub worked: Vec<Span<'static>>, |
| 66 | /// Snapshot of the global retry-status surface (#499). Sampled once |
| 67 | /// at props-build time and rendered as a foreground banner on the |
| 68 | /// left of the footer when active. Captured here (rather than read |
| 69 | /// from `retry_status` at render time) so tests can pin a |
| 70 | /// deterministic state without racing the parallel runner. |
| 71 | pub retry: crate::retry_status::RetryState, |
| 72 | /// Session-cost chip spans (empty when below the display threshold). |
| 73 | /// Rendered in the left cluster (after the model name) — cost is steady |
| 74 | /// info, not a transient signal, so it lives with mode and model. |
| 75 | pub cost: Vec<Span<'static>>, |
| 76 | /// Optional toast that, when present, replaces the left status line. |
| 77 | pub toast: Option<FooterToast>, |
| 78 | /// When `Some(frame_idx)`, the gap between the left status line and the |
| 79 | /// right-hand chips is filled with an animated water-spout strip keyed |
| 80 | /// off `frame_idx` (deterministic given the frame). `None` keeps the gap |
| 81 | /// as plain whitespace, which is the idle/ready state. |
| 82 | pub working_strip_frame: Option<u64>, |
| 83 | } |
| 84 | |
| 85 | const WAVE_GLYPHS: [char; 8] = [ |
| 86 | '\u{2581}', // ▁ |
| 87 | '\u{2582}', // ▂ |
| 88 | '\u{2583}', // ▃ |
| 89 | '\u{2584}', // ▄ |
| 90 | '\u{2585}', // ▅ |
| 91 | '\u{2586}', // ▆ |
| 92 | '\u{2587}', // ▇ |
| 93 | '\u{2588}', // █ |
| 94 | ]; |
| 95 | |
| 96 | /// One frame of the footer's live-work wave animation. `col` is the cell |
| 97 | /// index inside the strip, `width` the strip's total width, `frame` the raw |
| 98 | /// millisecond counter. Returns the glyph that should appear in that cell on |
| 99 | /// that frame. |
| 100 | /// |
| 101 | /// Visual: a full-width phase-shifted wave made from one-cell block-height |
| 102 | /// glyphs. The earlier crest-pair animation only changed when rounded crest |
| 103 | /// positions crossed a terminal cell boundary; at an 80 ms repaint cadence it |
| 104 | /// read as visible hops. Sampling a few moving sine components gives every |
| 105 | /// repaint a new surface while keeping the math deterministic for tests. |
| 106 | #[must_use] |
| 107 | pub fn footer_working_strip_glyph_at(col: usize, width: usize, frame: u64) -> char { |
| 108 | if width == 0 { |
| 109 | return ' '; |
| 110 | } |
| 111 | |
| 112 | let t = frame as f64 / 1000.0; |
| 113 | let x = col as f64; |
| 114 | |
| 115 | let primary = (x * 0.52 - t * 8.0).sin(); |
| 116 | let swell = (x * 0.18 + t * 3.1).sin() * 0.35; |
| 117 | let shimmer = (x * 1.35 - t * 11.0).sin() * 0.12; |
| 118 | let value = ((primary + swell + shimmer) / 1.47).clamp(-1.0, 1.0); |
| 119 | let normalized = (value + 1.0) * 0.5; |
| 120 | let idx = (normalized * (WAVE_GLYPHS.len() - 1) as f64).round() as usize; |
| 121 | WAVE_GLYPHS[idx.min(WAVE_GLYPHS.len() - 1)] |
| 122 | } |
| 123 | |
| 124 | /// Build the per-frame live-work wave string of `width` characters. Empty string |
| 125 | /// when width is 0. The result is the same visual width as requested (one |
| 126 | /// char per column for the selected block-height glyphs) and is safe to drop |
| 127 | /// into a `Span` between the footer's left and right segments. |
| 128 | #[must_use] |
| 129 | pub fn footer_working_strip_string(width: usize, frame: u64) -> String { |
| 130 | let mut out = String::with_capacity(width * 4); |
| 131 | for col in 0..width { |
| 132 | out.push(footer_working_strip_glyph_at(col, width, frame)); |
| 133 | } |
| 134 | out |
| 135 | } |
| 136 | |
| 137 | /// Pulse the localized "working" label through 0–3 trailing ASCII dots |
| 138 | /// keyed off `frame`. The cycle period is 4 frames (matching the four |
| 139 | /// states), so adjacent ticks visibly differ. Dots stay ASCII regardless |
| 140 | /// of locale so the animation reads identically across scripts. Returns a |
| 141 | /// `String` so callers can drop it into a `Span::styled` without lifetime |
| 142 | /// gymnastics. |
| 143 | #[must_use] |
| 144 | pub fn footer_working_label(frame: u64, locale: Locale) -> String { |
| 145 | let dots = (frame % 4) as usize; |
| 146 | let base = tr(locale, MessageId::FooterWorking); |
| 147 | let mut out = String::with_capacity(base.len() + dots); |
| 148 | out.push_str(base); |
| 149 | for _ in 0..dots { |
| 150 | out.push('.'); |
| 151 | } |
| 152 | out |
| 153 | } |
| 154 | |
| 155 | /// Build a "N agents" chip span list when there are sub-agents in flight. |
| 156 | /// Empty list when N == 0 hides the chip entirely. Singular for N == 1 |
| 157 | /// reads naturally; plural otherwise. The pluralization template lives in |
| 158 | /// the locale registry so CJK locales can render the count without the |
| 159 | /// English plural-`s` artefact. |
| 160 | #[must_use] |
| 161 | pub fn footer_agents_chip(running: usize, locale: Locale) -> Vec<Span<'static>> { |
| 162 | if running == 0 { |
| 163 | return Vec::new(); |
| 164 | } |
| 165 | let text = if running == 1 { |
| 166 | tr(locale, MessageId::FooterAgentSingular).to_string() |
| 167 | } else { |
| 168 | tr(locale, MessageId::FooterAgentsPlural).replace("{count}", &running.to_string()) |
| 169 | }; |
| 170 | vec![Span::styled( |
| 171 | text, |
| 172 | Style::default().fg(palette::DEEPSEEK_SKY), |
| 173 | )] |
| 174 | } |
| 175 | |
| 176 | /// Build the cumulative-elapsed chip ("worked 3h 12m") for the |
| 177 | /// footer's right cluster (#448). Hidden during the first minute of |
| 178 | /// a session so a fresh launch doesn't render a noisy `worked 5s` |
| 179 | /// indicator that immediately starts ticking. Above the threshold, |
| 180 | /// reuses [`crate::tui::notifications::humanize_duration`] for |
| 181 | /// consistent w/d/h/m formatting. |
| 182 | #[must_use] |
| 183 | pub fn footer_worked_chip(elapsed: std::time::Duration) -> Vec<Span<'static>> { |
| 184 | if elapsed < std::time::Duration::from_secs(60) { |
| 185 | return Vec::new(); |
| 186 | } |
| 187 | let label = format!( |
| 188 | "worked {}", |
| 189 | crate::tui::notifications::humanize_duration(elapsed) |
| 190 | ); |
| 191 | vec![Span::styled( |
| 192 | label, |
| 193 | Style::default().fg(palette::TEXT_MUTED), |
| 194 | )] |
| 195 | } |
| 196 | |
| 197 | /// Build the "MCP M/N" health chip (#502) from the user's stored |
| 198 | /// snapshot. `connected` is the number of servers currently reachable; |
| 199 | /// `configured` is the number declared in the user's MCP config. When |
| 200 | /// `configured` is zero the chip is hidden entirely. |
| 201 | /// |
| 202 | /// Colour-codes the count by health: |
| 203 | /// - all reachable → success |
| 204 | /// - some reachable → warning |
| 205 | /// - none reachable but at least one configured → error |
| 206 | /// - configured but no live snapshot yet → muted (count only) |
| 207 | #[must_use] |
| 208 | pub fn footer_mcp_chip(connected: Option<usize>, configured: usize) -> Vec<Span<'static>> { |
| 209 | if configured == 0 { |
| 210 | return Vec::new(); |
| 211 | } |
| 212 | let (label, color) = match connected { |
| 213 | None => (format!("MCP {configured}"), palette::TEXT_MUTED), |
| 214 | Some(c) if c == configured => (format!("MCP {c}/{configured}"), palette::STATUS_SUCCESS), |
| 215 | Some(0) => (format!("MCP 0/{configured}"), palette::STATUS_ERROR), |
| 216 | Some(c) => (format!("MCP {c}/{configured}"), palette::STATUS_WARNING), |
| 217 | }; |
| 218 | vec![Span::styled(label, Style::default().fg(color))] |
| 219 | } |
| 220 | |
| 221 | /// A status toast routed to the footer's left segment for a short time. |
| 222 | #[derive(Debug, Clone)] |
| 223 | pub struct FooterToast { |
| 224 | pub text: String, |
| 225 | pub color: Color, |
| 226 | } |
| 227 | |
| 228 | impl FooterProps { |
| 229 | /// Build footer props from common app state. Helpers in `tui/ui.rs` |
| 230 | /// (e.g. `footer_state_label`, `footer_coherence_spans`) supply the |
| 231 | /// pre-styled spans and labels — this constructor just bundles them. |
| 232 | /// |
| 233 | /// Argument fan-out is intentional: each input maps 1:1 to a piece of |
| 234 | /// pre-computed footer content the caller resolved from `App`. Forcing |
| 235 | /// these into a builder would obscure the call site without making the |
| 236 | /// data flow any clearer. |
| 237 | #[must_use] |
| 238 | #[allow(clippy::too_many_arguments)] |
| 239 | pub fn from_app( |
| 240 | app: &App, |
| 241 | toast: Option<FooterToast>, |
| 242 | state_label: &'static str, |
| 243 | state_color: Color, |
| 244 | coherence: Vec<Span<'static>>, |
| 245 | agents: Vec<Span<'static>>, |
| 246 | reasoning_replay: Vec<Span<'static>>, |
| 247 | cache: Vec<Span<'static>>, |
| 248 | cost: Vec<Span<'static>>, |
| 249 | ) -> Self { |
| 250 | let (mode_label, mode_color) = mode_style(app); |
| 251 | // MCP chip (#502) — passive, derived from the user's existing |
| 252 | // snapshot. `connected` is `None` until the user runs `/mcp`, |
| 253 | // which is the same trigger the issue spec accepts for now. |
| 254 | let mcp_configured = app.mcp_configured_count; |
| 255 | let mcp_connected = app |
| 256 | .mcp_snapshot |
| 257 | .as_ref() |
| 258 | .map(|s| s.servers.iter().filter(|server| server.connected).count()); |
| 259 | let mcp = footer_mcp_chip(mcp_connected, mcp_configured); |
| 260 | // #448: cumulative work-time chip. Sums actual turn durations |
| 261 | // (set on `TurnComplete`) rather than wall-clock uptime — a TUI |
| 262 | // that's been open and idle for 4 minutes shouldn't claim |
| 263 | // "worked 4m". The chip stays empty until enough turns add up |
| 264 | // to cross the 60s threshold inside `footer_worked_chip`. |
| 265 | let worked = footer_worked_chip(app.cumulative_turn_duration); |
| 266 | Self { |
| 267 | model: app.model_display_label(), |
| 268 | mode_label, |
| 269 | mode_color, |
| 270 | text_dim_color: app.ui_theme.text_dim, |
| 271 | text_hint_color: app.ui_theme.text_hint, |
| 272 | text_muted_color: app.ui_theme.text_muted, |
| 273 | footer_bg: app.ui_theme.footer_bg, |
| 274 | state_label: state_label.to_string(), |
| 275 | state_color, |
| 276 | coherence, |
| 277 | agents, |
| 278 | reasoning_replay, |
| 279 | cache, |
| 280 | mcp, |
| 281 | worked, |
| 282 | cost, |
| 283 | toast, |
| 284 | working_strip_frame: None, |
| 285 | retry: crate::retry_status::snapshot(), |
| 286 | } |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | fn mode_style(app: &App) -> (&'static str, Color) { |
| 291 | let label = match app.mode { |
| 292 | AppMode::Agent => "agent", |
| 293 | AppMode::Yolo => "yolo", |
| 294 | AppMode::Plan => "plan", |
| 295 | }; |
| 296 | let color = match app.mode { |
| 297 | AppMode::Agent => app.ui_theme.mode_agent, |
| 298 | AppMode::Yolo => app.ui_theme.mode_yolo, |
| 299 | AppMode::Plan => app.ui_theme.mode_plan, |
| 300 | }; |
| 301 | (label, color) |
| 302 | } |
| 303 | |
| 304 | /// Pure-render footer. Build once per frame, then `render(area, buf)`. |
| 305 | pub struct FooterWidget { |
| 306 | props: FooterProps, |
| 307 | } |
| 308 | |
| 309 | impl FooterWidget { |
| 310 | #[must_use] |
| 311 | pub fn new(props: FooterProps) -> Self { |
| 312 | Self { props } |
| 313 | } |
| 314 | |
| 315 | fn auxiliary_spans(&self, max_width: usize) -> Vec<Span<'static>> { |
| 316 | // `cost` is rendered in the left cluster now — keep it out of the |
| 317 | // right-hand chip parade. Coherence / agents / replay / cache are |
| 318 | // transient signals; they belong on the right where they appear and |
| 319 | // disappear without disturbing the steady mode·model·cost line. |
| 320 | let parts: Vec<&Vec<Span<'static>>> = [ |
| 321 | &self.props.coherence, |
| 322 | &self.props.agents, |
| 323 | &self.props.reasoning_replay, |
| 324 | &self.props.cache, |
| 325 | &self.props.mcp, |
| 326 | // `worked` is the lowest-priority chip — drops first under |
| 327 | // narrow widths (the priority loop below removes from the |
| 328 | // tail). `cost` is steady info and stays in the left |
| 329 | // cluster where the eye finds it without scanning. |
| 330 | &self.props.worked, |
| 331 | ] |
| 332 | .into_iter() |
| 333 | .filter(|spans| !spans.is_empty()) |
| 334 | .collect(); |
| 335 | |
| 336 | // Try to fit as many parts as possible, dropping from the end. |
| 337 | for end in (0..=parts.len()).rev() { |
| 338 | let mut combined: Vec<Span<'static>> = Vec::new(); |
| 339 | for (i, part) in parts[..end].iter().enumerate() { |
| 340 | if i > 0 { |
| 341 | combined.push(Span::raw(" ")); |
| 342 | } |
| 343 | combined.extend(part.iter().cloned()); |
| 344 | } |
| 345 | if span_width(&combined) <= max_width { |
| 346 | return combined; |
| 347 | } |
| 348 | } |
| 349 | Vec::new() |
| 350 | } |
| 351 | |
| 352 | fn toast_spans(toast: &FooterToast, max_width: usize) -> Vec<Span<'static>> { |
| 353 | let truncated = truncate_to_width(&toast.text, max_width.max(1)); |
| 354 | vec![Span::styled(truncated, Style::default().fg(toast.color))] |
| 355 | } |
| 356 | |
| 357 | /// Build the left status line with priority-ordered hint dropping. |
| 358 | /// |
| 359 | /// Priority order (highest to lowest — last to drop): |
| 360 | /// 1. Mode label (always visible at any width; truncated only as a last resort) |
| 361 | /// 2. Model name (always visible; then truncated mid-word once status & cost are gone) |
| 362 | /// 3. Cost chip — drops second after status (steady-info still wants to be visible) |
| 363 | /// 4. Status label (e.g. "working", "draft") — drops first when space is tight |
| 364 | /// |
| 365 | /// At every width ≥40 cols the line never wraps mid-hint: the widget |
| 366 | /// chooses one of (`mode · model · cost · status`, `mode · model · cost`, |
| 367 | /// `mode · model`, `mode`) and renders that single line within |
| 368 | /// `max_width`. Cost lives between model and status so the eye finds |
| 369 | /// "what's this run going to cost me" without scanning past the wave. |
| 370 | fn status_line_spans(&self, max_width: usize) -> Vec<Span<'static>> { |
| 371 | if max_width == 0 { |
| 372 | return Vec::new(); |
| 373 | } |
| 374 | |
| 375 | let mode_label = self.props.mode_label; |
| 376 | let sep = " \u{00B7} "; |
| 377 | let model = self.props.model.as_str(); |
| 378 | let show_status = self.props.state_label != "ready"; |
| 379 | let status_label = self.props.state_label.as_str(); |
| 380 | let cost_text = spans_text(&self.props.cost); |
| 381 | let show_cost = !cost_text.is_empty(); |
| 382 | |
| 383 | let mode_w = mode_label.width(); |
| 384 | let sep_w = sep.width(); |
| 385 | let model_w = UnicodeWidthStr::width(model); |
| 386 | let status_w = status_label.width(); |
| 387 | let cost_w = cost_text.width(); |
| 388 | |
| 389 | // Tier 1: mode · model · cost · status — everything fits. |
| 390 | let full_w = mode_w |
| 391 | + sep_w |
| 392 | + model_w |
| 393 | + if show_cost { sep_w + cost_w } else { 0 } |
| 394 | + if show_status { sep_w + status_w } else { 0 }; |
| 395 | if (show_cost || show_status) && full_w <= max_width { |
| 396 | return self.build_status_line_spans( |
| 397 | mode_label, |
| 398 | model.to_string(), |
| 399 | show_cost.then(|| cost_text.clone()), |
| 400 | show_status.then_some(status_label), |
| 401 | ); |
| 402 | } |
| 403 | |
| 404 | // Tier 2: mode · model · cost — drop status first. |
| 405 | if show_cost { |
| 406 | let with_cost_w = mode_w + sep_w + model_w + sep_w + cost_w; |
| 407 | if with_cost_w <= max_width { |
| 408 | return self.build_status_line_spans( |
| 409 | mode_label, |
| 410 | model.to_string(), |
| 411 | Some(cost_text.clone()), |
| 412 | None, |
| 413 | ); |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | // Tier 3: mode · model — drop cost too. |
| 418 | let mode_model_w = mode_w + sep_w + model_w; |
| 419 | if mode_model_w <= max_width { |
| 420 | return self.build_status_line_spans(mode_label, model.to_string(), None, None); |
| 421 | } |
| 422 | |
| 423 | // Tier 4: mode · <truncated model> — keep both labels visible by |
| 424 | // ellipsizing the model name. Only do this when there is enough room |
| 425 | // for at least the ellipsis ("..."). Below that we drop to mode-only. |
| 426 | let prefix_w = mode_w + sep_w; |
| 427 | if prefix_w < max_width { |
| 428 | let model_budget = max_width - prefix_w; |
| 429 | if model_budget >= 4 { |
| 430 | let truncated = truncate_to_width(model, model_budget); |
| 431 | if !truncated.is_empty() { |
| 432 | return self.build_status_line_spans(mode_label, truncated, None, None); |
| 433 | } |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | // Tier 5: mode-only. If even the mode label cannot fit, truncate it |
| 438 | // so the footer never wraps to a second row. |
| 439 | if mode_w <= max_width { |
| 440 | return vec![Span::styled( |
| 441 | mode_label.to_string(), |
| 442 | Style::default().fg(self.props.mode_color), |
| 443 | )]; |
| 444 | } |
| 445 | vec![Span::styled( |
| 446 | truncate_to_width(mode_label, max_width), |
| 447 | Style::default().fg(self.props.mode_color), |
| 448 | )] |
| 449 | } |
| 450 | |
| 451 | fn build_status_line_spans( |
| 452 | &self, |
| 453 | mode_label: &'static str, |
| 454 | model_label: String, |
| 455 | cost: Option<String>, |
| 456 | status: Option<&str>, |
| 457 | ) -> Vec<Span<'static>> { |
| 458 | let sep = " \u{00B7} "; |
| 459 | let mut spans: Vec<Span<'static>> = Vec::new(); |
| 460 | // Skip the mode chip when the user has toggled it off via |
| 461 | // `/statusline`. The widget no longer assumes mode is always |
| 462 | // present so an opt-out user doesn't see a stray separator. |
| 463 | if !mode_label.is_empty() { |
| 464 | spans.push(Span::styled( |
| 465 | mode_label.to_string(), |
| 466 | Style::default().fg(self.props.mode_color), |
| 467 | )); |
| 468 | } |
| 469 | // Same treatment for the model label — gating both keeps the bar |
| 470 | // visually tidy when only auxiliary chips remain. |
| 471 | if !model_label.is_empty() { |
| 472 | if !spans.is_empty() { |
| 473 | spans.push(Span::styled( |
| 474 | sep.to_string(), |
| 475 | Style::default().fg(self.props.text_dim_color), |
| 476 | )); |
| 477 | } |
| 478 | spans.push(Span::styled( |
| 479 | model_label, |
| 480 | Style::default().fg(self.props.text_hint_color), |
| 481 | )); |
| 482 | } |
| 483 | if let Some(cost_text) = cost { |
| 484 | if !spans.is_empty() { |
| 485 | spans.push(Span::styled( |
| 486 | sep.to_string(), |
| 487 | Style::default().fg(self.props.text_dim_color), |
| 488 | )); |
| 489 | } |
| 490 | spans.push(Span::styled( |
| 491 | cost_text, |
| 492 | Style::default().fg(self.props.text_muted_color), |
| 493 | )); |
| 494 | } |
| 495 | if let Some(status_label) = status { |
| 496 | if !spans.is_empty() { |
| 497 | spans.push(Span::styled( |
| 498 | sep.to_string(), |
| 499 | Style::default().fg(self.props.text_dim_color), |
| 500 | )); |
| 501 | } |
| 502 | spans.push(Span::styled( |
| 503 | status_label.to_string(), |
| 504 | Style::default().fg(self.props.state_color), |
| 505 | )); |
| 506 | } |
| 507 | spans |
| 508 | } |
| 509 | } |
| 510 | |
| 511 | fn spans_text(spans: &[Span<'_>]) -> String { |
| 512 | spans.iter().map(|s| s.content.as_ref()).collect::<String>() |
| 513 | } |
| 514 | |
| 515 | /// Render the retry banner (#499) when the props' captured snapshot |
| 516 | /// reports an active retry or a final failure. Returns `None` when idle |
| 517 | /// so callers fall back to the regular status line / toast. |
| 518 | fn retry_banner_spans(max_width: usize, props: &FooterProps) -> Option<Vec<Span<'static>>> { |
| 519 | let (label, color) = match &props.retry { |
| 520 | crate::retry_status::RetryState::Active(banner) => { |
| 521 | let secs = props.retry.seconds_remaining().unwrap_or(0); |
| 522 | // Round to 1s — we redraw each frame anyway so the |
| 523 | // countdown ticks visually without us having to schedule |
| 524 | // anything extra. |
| 525 | ( |
| 526 | format!("⟳ retry {} in {secs}s — {}", banner.attempt, banner.reason), |
| 527 | crate::palette::STATUS_WARNING, |
| 528 | ) |
| 529 | } |
| 530 | crate::retry_status::RetryState::Failed { reason, .. } => { |
| 531 | (format!("× failed: {reason}"), crate::palette::STATUS_ERROR) |
| 532 | } |
| 533 | crate::retry_status::RetryState::Idle => return None, |
| 534 | }; |
| 535 | let truncated = truncate_to_width(&label, max_width); |
| 536 | Some(vec![Span::styled(truncated, Style::default().fg(color))]) |
| 537 | } |
| 538 | |
| 539 | impl Renderable for FooterWidget { |
| 540 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 541 | if area.height == 0 || area.width == 0 { |
| 542 | return; |
| 543 | } |
| 544 | let available_width = area.width as usize; |
| 545 | if available_width == 0 { |
| 546 | return; |
| 547 | } |
| 548 | |
| 549 | let right_spans = self.auxiliary_spans(available_width); |
| 550 | let right_width = span_width(&right_spans); |
| 551 | let min_gap = if right_width > 0 { 2 } else { 0 }; |
| 552 | let max_left_width = available_width |
| 553 | .saturating_sub(right_width) |
| 554 | .saturating_sub(min_gap) |
| 555 | .max(1); |
| 556 | |
| 557 | let left_spans = if let Some(banner) = retry_banner_spans(max_left_width, &self.props) { |
| 558 | // Retry banner takes precedence over toast and the regular |
| 559 | // status line so the user sees it loud and clear (#499). |
| 560 | // The banner clears automatically on success or on the next |
| 561 | // `TurnStarted` (engine emits the clear). |
| 562 | banner |
| 563 | } else if let Some(toast) = self.props.toast.as_ref() { |
| 564 | Self::toast_spans(toast, max_left_width) |
| 565 | } else { |
| 566 | self.status_line_spans(max_left_width) |
| 567 | }; |
| 568 | |
| 569 | let left_width = span_width(&left_spans); |
| 570 | let spacer_width = available_width.saturating_sub(left_width + right_width); |
| 571 | |
| 572 | // When a turn is in flight, fill the gap with a thin animated water- |
| 573 | // spout strip; otherwise the gap stays as plain whitespace. |
| 574 | let spacer_span = match self.props.working_strip_frame { |
| 575 | Some(frame) if spacer_width > 0 => Span::styled( |
| 576 | footer_working_strip_string(spacer_width, frame), |
| 577 | Style::default().fg(palette::DEEPSEEK_SKY), |
| 578 | ), |
| 579 | _ => Span::raw(" ".repeat(spacer_width)), |
| 580 | }; |
| 581 | |
| 582 | let mut all_spans = left_spans; |
| 583 | all_spans.push(spacer_span); |
| 584 | all_spans.extend(right_spans); |
| 585 | |
| 586 | let paragraph = |
| 587 | Paragraph::new(Line::from(all_spans)).style(Style::default().bg(self.props.footer_bg)); |
| 588 | paragraph.render(area, buf); |
| 589 | } |
| 590 | |
| 591 | fn desired_height(&self, _width: u16) -> u16 { |
| 592 | 1 |
| 593 | } |
| 594 | } |
| 595 | |
| 596 | fn span_width(spans: &[Span<'_>]) -> usize { |
| 597 | spans.iter().map(|span| span.content.width()).sum() |
| 598 | } |
| 599 | |
| 600 | fn truncate_to_width(text: &str, max_width: usize) -> String { |
| 601 | if max_width == 0 { |
| 602 | return String::new(); |
| 603 | } |
| 604 | if UnicodeWidthStr::width(text) <= max_width { |
| 605 | return text.to_string(); |
| 606 | } |
| 607 | if max_width <= 3 { |
| 608 | return text.chars().take(max_width).collect(); |
| 609 | } |
| 610 | |
| 611 | let mut out = String::new(); |
| 612 | let mut width = 0usize; |
| 613 | let limit = max_width.saturating_sub(3); |
| 614 | for ch in text.chars() { |
| 615 | let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); |
| 616 | if width + ch_width > limit { |
| 617 | break; |
| 618 | } |
| 619 | out.push(ch); |
| 620 | width += ch_width; |
| 621 | } |
| 622 | out.push_str("..."); |
| 623 | out |
| 624 | } |
| 625 | |
| 626 | #[cfg(test)] |
| 627 | mod tests { |
| 628 | use super::{FooterProps, FooterWidget, Renderable}; |
| 629 | use crate::config::Config; |
| 630 | use crate::localization::Locale; |
| 631 | use crate::palette; |
| 632 | use crate::tui::app::{App, AppMode, TuiOptions}; |
| 633 | use ratatui::{ |
| 634 | style::{Color, Style}, |
| 635 | text::Span, |
| 636 | }; |
| 637 | use std::path::PathBuf; |
| 638 | |
| 639 | fn make_app() -> App { |
| 640 | let options = TuiOptions { |
| 641 | model: "deepseek-v4-flash".to_string(), |
| 642 | workspace: PathBuf::from("."), |
| 643 | config_path: None, |
| 644 | config_profile: None, |
| 645 | allow_shell: false, |
| 646 | use_alt_screen: true, |
| 647 | use_mouse_capture: false, |
| 648 | use_bracketed_paste: true, |
| 649 | max_subagents: 1, |
| 650 | skills_dir: PathBuf::from("."), |
| 651 | memory_path: PathBuf::from("memory.md"), |
| 652 | notes_path: PathBuf::from("notes.txt"), |
| 653 | mcp_config_path: PathBuf::from("mcp.json"), |
| 654 | use_memory: false, |
| 655 | start_in_agent_mode: true, |
| 656 | skip_onboarding: true, |
| 657 | yolo: false, |
| 658 | resume_session_id: None, |
| 659 | initial_input: None, |
| 660 | }; |
| 661 | let mut app = App::new(options, &Config::default()); |
| 662 | // App::new may pick up `default_model` from a local user Settings |
| 663 | // file, which overrides the option above. Pin the model explicitly |
| 664 | // so these tests are independent of any host-side configuration. |
| 665 | app.model = "deepseek-v4-flash".to_string(); |
| 666 | app |
| 667 | } |
| 668 | |
| 669 | fn idle_props_for(app: &App) -> FooterProps { |
| 670 | let mut props = FooterProps::from_app( |
| 671 | app, |
| 672 | None, |
| 673 | "ready", |
| 674 | palette::TEXT_MUTED, |
| 675 | Vec::<Span<'static>>::new(), |
| 676 | Vec::<Span<'static>>::new(), |
| 677 | Vec::<Span<'static>>::new(), |
| 678 | Vec::<Span<'static>>::new(), |
| 679 | Vec::<Span<'static>>::new(), |
| 680 | ); |
| 681 | // `from_app` reads the process-wide retry-status surface; pin |
| 682 | // `Idle` so footer tests don't pick up state set by retry-banner |
| 683 | // tests running in parallel. |
| 684 | props.retry = crate::retry_status::RetryState::Idle; |
| 685 | props |
| 686 | } |
| 687 | |
| 688 | #[test] |
| 689 | fn from_app_idle_state_carries_ready_label_and_no_chips() { |
| 690 | let app = make_app(); |
| 691 | let props = idle_props_for(&app); |
| 692 | |
| 693 | assert_eq!(props.state_label, "ready"); |
| 694 | assert_eq!(props.state_color, palette::TEXT_MUTED); |
| 695 | assert_eq!(props.mode_label, "agent"); |
| 696 | assert_eq!(props.mode_color, palette::MODE_AGENT); |
| 697 | assert_eq!(props.text_dim_color, palette::TEXT_DIM); |
| 698 | assert_eq!(props.text_hint_color, palette::TEXT_HINT); |
| 699 | assert_eq!(props.text_muted_color, palette::TEXT_MUTED); |
| 700 | assert_eq!(props.model, "deepseek-v4-flash"); |
| 701 | assert!(props.coherence.is_empty()); |
| 702 | assert!(props.agents.is_empty()); |
| 703 | assert!(props.cache.is_empty()); |
| 704 | assert!(props.cost.is_empty()); |
| 705 | assert!(props.reasoning_replay.is_empty()); |
| 706 | // #448: fresh apps don't get a `worked` chip until completed |
| 707 | // turns have added up to >= 60s of model work. A freshly-built |
| 708 | // App has cumulative_turn_duration == 0 so the chip is empty. |
| 709 | assert!(props.worked.is_empty()); |
| 710 | assert!(props.toast.is_none()); |
| 711 | } |
| 712 | |
| 713 | #[test] |
| 714 | fn worked_chip_tracks_completed_turn_time_not_session_uptime() { |
| 715 | // Regression test for the v0.8.8 takedown: the chip used to |
| 716 | // read `App::session_started_at.elapsed()`, so a TUI that had |
| 717 | // been open and idle for several minutes claimed "worked 3m" |
| 718 | // even though no turn had ever fired. The chip now sources |
| 719 | // from `App::cumulative_turn_duration`, which is only ever |
| 720 | // incremented on `TurnComplete`. Pin both directions: |
| 721 | // |
| 722 | // 1. cumulative == 0 (no turn finished yet) → empty |
| 723 | // 2. cumulative crosses 60s (real work) → label shows |
| 724 | // 3. wall-clock since launch is irrelevant → not consulted |
| 725 | let mut app = make_app(); |
| 726 | // The whole point: cumulative_turn_duration starts at zero, |
| 727 | // so however long the TUI has been open the chip stays empty |
| 728 | // until a turn actually completes and adds time. |
| 729 | let props = idle_props_for(&app); |
| 730 | assert!( |
| 731 | props.worked.is_empty(), |
| 732 | "idle app with zero cumulative turn time must not show worked chip" |
| 733 | ); |
| 734 | |
| 735 | // A real turn finishes for 90s of model work — chip lights up. |
| 736 | // (`humanize_duration` keeps both units when both are non-zero, |
| 737 | // so 90s renders as `1m 30s`, not `1m`.) |
| 738 | app.cumulative_turn_duration = std::time::Duration::from_secs(90); |
| 739 | let props = idle_props_for(&app); |
| 740 | let text: String = props |
| 741 | .worked |
| 742 | .iter() |
| 743 | .map(|s| s.content.as_ref()) |
| 744 | .collect::<String>(); |
| 745 | assert_eq!(text, "worked 1m 30s"); |
| 746 | } |
| 747 | |
| 748 | #[test] |
| 749 | fn footer_worked_chip_hidden_below_one_minute() { |
| 750 | use std::time::Duration; |
| 751 | for secs in [0, 1, 30, 59] { |
| 752 | let chip = super::footer_worked_chip(Duration::from_secs(secs)); |
| 753 | assert!( |
| 754 | chip.is_empty(), |
| 755 | "worked chip must be hidden at {secs}s; got {chip:?}" |
| 756 | ); |
| 757 | } |
| 758 | } |
| 759 | |
| 760 | #[test] |
| 761 | fn footer_worked_chip_shows_humanized_label_above_threshold() { |
| 762 | use std::time::Duration; |
| 763 | // 1 minute on the dot — boundary, must render. |
| 764 | let chip = super::footer_worked_chip(Duration::from_secs(60)); |
| 765 | let text: String = chip.iter().map(|s| s.content.as_ref()).collect(); |
| 766 | assert_eq!(text, "worked 1m"); |
| 767 | |
| 768 | // 3h 12m — the issue's golden example. |
| 769 | let chip = super::footer_worked_chip(Duration::from_secs(11_550)); |
| 770 | let text: String = chip.iter().map(|s| s.content.as_ref()).collect(); |
| 771 | assert_eq!(text, "worked 3h 12m"); |
| 772 | |
| 773 | // Multi-day session — exercises the d/h band. |
| 774 | let chip = super::footer_worked_chip(Duration::from_secs(2 * 86_400 + 5 * 3600)); |
| 775 | let text: String = chip.iter().map(|s| s.content.as_ref()).collect(); |
| 776 | assert_eq!(text, "worked 2d 5h"); |
| 777 | } |
| 778 | |
| 779 | #[test] |
| 780 | fn from_app_loading_state_uses_thinking_label_and_warning_color() { |
| 781 | let app = make_app(); |
| 782 | let props = FooterProps::from_app( |
| 783 | &app, |
| 784 | None, |
| 785 | "thinking \u{238B}", |
| 786 | palette::STATUS_WARNING, |
| 787 | Vec::<Span<'static>>::new(), |
| 788 | Vec::<Span<'static>>::new(), |
| 789 | Vec::<Span<'static>>::new(), |
| 790 | Vec::<Span<'static>>::new(), |
| 791 | Vec::<Span<'static>>::new(), |
| 792 | ); |
| 793 | |
| 794 | assert!(props.state_label.starts_with("thinking")); |
| 795 | assert_eq!(props.state_color, palette::STATUS_WARNING); |
| 796 | } |
| 797 | |
| 798 | #[test] |
| 799 | fn from_app_statusline_colors_come_from_ui_theme() { |
| 800 | let mut app = make_app(); |
| 801 | app.ui_theme.mode_agent = Color::Rgb(1, 2, 3); |
| 802 | app.ui_theme.text_dim = Color::Rgb(4, 5, 6); |
| 803 | app.ui_theme.text_hint = Color::Rgb(7, 8, 9); |
| 804 | app.ui_theme.text_muted = Color::Rgb(10, 11, 12); |
| 805 | app.ui_theme.footer_bg = Color::Rgb(13, 14, 15); |
| 806 | |
| 807 | let props = idle_props_for(&app); |
| 808 | |
| 809 | assert_eq!(props.mode_color, Color::Rgb(1, 2, 3)); |
| 810 | assert_eq!(props.text_dim_color, Color::Rgb(4, 5, 6)); |
| 811 | assert_eq!(props.text_hint_color, Color::Rgb(7, 8, 9)); |
| 812 | assert_eq!(props.text_muted_color, Color::Rgb(10, 11, 12)); |
| 813 | assert_eq!(props.footer_bg, Color::Rgb(13, 14, 15)); |
| 814 | } |
| 815 | |
| 816 | #[test] |
| 817 | fn render_applies_footer_background_to_full_row() { |
| 818 | let mut app = make_app(); |
| 819 | app.ui_theme.footer_bg = Color::Rgb(13, 14, 15); |
| 820 | let props = idle_props_for(&app); |
| 821 | let widget = FooterWidget::new(props); |
| 822 | let area = ratatui::layout::Rect::new(0, 0, 60, 1); |
| 823 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 824 | |
| 825 | widget.render(area, &mut buf); |
| 826 | |
| 827 | for x in 0..area.width { |
| 828 | assert_eq!(buf[(x, 0)].bg, Color::Rgb(13, 14, 15)); |
| 829 | } |
| 830 | } |
| 831 | |
| 832 | // ---- agents chip wording ---- |
| 833 | #[test] |
| 834 | fn footer_agents_chip_is_empty_when_no_agents_running() { |
| 835 | let chip = super::footer_agents_chip(0, Locale::En); |
| 836 | assert!(chip.is_empty(), "0 agents in flight → no chip"); |
| 837 | } |
| 838 | |
| 839 | #[test] |
| 840 | fn footer_agents_chip_uses_singular_for_one() { |
| 841 | let chip = super::footer_agents_chip(1, Locale::En); |
| 842 | assert_eq!(chip.len(), 1); |
| 843 | assert_eq!(chip[0].content.as_ref(), "1 agent"); |
| 844 | } |
| 845 | |
| 846 | #[test] |
| 847 | fn footer_agents_chip_uses_plural_for_many() { |
| 848 | let chip = super::footer_agents_chip(3, Locale::En); |
| 849 | assert_eq!(chip.len(), 1); |
| 850 | assert_eq!(chip[0].content.as_ref(), "3 agents"); |
| 851 | } |
| 852 | |
| 853 | #[test] |
| 854 | fn footer_agents_chip_renders_into_widget() { |
| 855 | let app = make_app(); |
| 856 | let agents = super::footer_agents_chip(2, Locale::En); |
| 857 | let props = FooterProps::from_app( |
| 858 | &app, |
| 859 | None, |
| 860 | "ready", |
| 861 | palette::TEXT_MUTED, |
| 862 | Vec::<Span<'static>>::new(), |
| 863 | agents, |
| 864 | Vec::<Span<'static>>::new(), |
| 865 | Vec::<Span<'static>>::new(), |
| 866 | Vec::<Span<'static>>::new(), |
| 867 | ); |
| 868 | let widget = FooterWidget::new(props); |
| 869 | let area = ratatui::layout::Rect::new(0, 0, 60, 1); |
| 870 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 871 | widget.render(area, &mut buf); |
| 872 | let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 873 | assert!( |
| 874 | rendered.contains("2 agents"), |
| 875 | "expected agents chip in render: {rendered:?}", |
| 876 | ); |
| 877 | } |
| 878 | |
| 879 | #[test] |
| 880 | fn from_app_mode_color_matches_mode_for_each_variant() { |
| 881 | let mut app = make_app(); |
| 882 | let cases = [ |
| 883 | (AppMode::Agent, "agent", palette::MODE_AGENT), |
| 884 | (AppMode::Yolo, "yolo", palette::MODE_YOLO), |
| 885 | (AppMode::Plan, "plan", palette::MODE_PLAN), |
| 886 | ]; |
| 887 | for (mode, expected_label, expected_color) in cases { |
| 888 | app.mode = mode; |
| 889 | let props = idle_props_for(&app); |
| 890 | assert_eq!( |
| 891 | props.mode_label, expected_label, |
| 892 | "label mismatch for {mode:?}", |
| 893 | ); |
| 894 | assert_eq!( |
| 895 | props.mode_color, expected_color, |
| 896 | "color mismatch for {mode:?}", |
| 897 | ); |
| 898 | } |
| 899 | } |
| 900 | |
| 901 | #[test] |
| 902 | fn footer_mcp_chip_hidden_when_no_servers() { |
| 903 | assert!(super::footer_mcp_chip(None, 0).is_empty()); |
| 904 | assert!(super::footer_mcp_chip(Some(0), 0).is_empty()); |
| 905 | } |
| 906 | |
| 907 | #[test] |
| 908 | fn footer_mcp_chip_shows_count_only_until_snapshot_arrives() { |
| 909 | let spans = super::footer_mcp_chip(None, 3); |
| 910 | let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); |
| 911 | assert_eq!(text, "MCP 3"); |
| 912 | } |
| 913 | |
| 914 | #[test] |
| 915 | fn footer_mcp_chip_uses_success_color_when_all_connected() { |
| 916 | let spans = super::footer_mcp_chip(Some(3), 3); |
| 917 | let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); |
| 918 | assert_eq!(text, "MCP 3/3"); |
| 919 | assert_eq!(spans[0].style.fg, Some(palette::STATUS_SUCCESS)); |
| 920 | } |
| 921 | |
| 922 | #[test] |
| 923 | fn footer_mcp_chip_uses_warning_color_when_partial() { |
| 924 | let spans = super::footer_mcp_chip(Some(2), 3); |
| 925 | let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); |
| 926 | assert_eq!(text, "MCP 2/3"); |
| 927 | assert_eq!(spans[0].style.fg, Some(palette::STATUS_WARNING)); |
| 928 | } |
| 929 | |
| 930 | #[test] |
| 931 | fn footer_mcp_chip_uses_error_color_when_zero_connected() { |
| 932 | let spans = super::footer_mcp_chip(Some(0), 3); |
| 933 | let text: String = spans.iter().map(|s| s.content.as_ref()).collect(); |
| 934 | assert_eq!(text, "MCP 0/3"); |
| 935 | assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR)); |
| 936 | } |
| 937 | |
| 938 | #[test] |
| 939 | fn render_shows_retry_banner_when_active() { |
| 940 | // Since `FooterProps::retry` is now a captured snapshot rather |
| 941 | // than a global read at render time, we can pin the state on |
| 942 | // the props directly without touching the global surface. |
| 943 | let app = make_app(); |
| 944 | let mut props = idle_props_for(&app); |
| 945 | props.retry = crate::retry_status::RetryState::Active(crate::retry_status::RetryBanner { |
| 946 | attempt: 2, |
| 947 | deadline: std::time::Instant::now() + std::time::Duration::from_secs(7), |
| 948 | reason: "rate limited".to_string(), |
| 949 | }); |
| 950 | let widget = FooterWidget::new(props); |
| 951 | let area = ratatui::layout::Rect::new(0, 0, 80, 1); |
| 952 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 953 | widget.render(area, &mut buf); |
| 954 | let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 955 | assert!( |
| 956 | rendered.contains("retry 2"), |
| 957 | "expected retry banner in render: {rendered:?}", |
| 958 | ); |
| 959 | assert!( |
| 960 | rendered.contains("rate limited"), |
| 961 | "expected reason in render: {rendered:?}", |
| 962 | ); |
| 963 | } |
| 964 | |
| 965 | #[test] |
| 966 | fn render_shows_failure_row_when_failed() { |
| 967 | let app = make_app(); |
| 968 | let mut props = idle_props_for(&app); |
| 969 | props.retry = crate::retry_status::RetryState::Failed { |
| 970 | reason: "upstream 500".to_string(), |
| 971 | since: std::time::Instant::now(), |
| 972 | }; |
| 973 | let widget = FooterWidget::new(props); |
| 974 | let area = ratatui::layout::Rect::new(0, 0, 80, 1); |
| 975 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 976 | widget.render(area, &mut buf); |
| 977 | let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 978 | assert!( |
| 979 | rendered.contains("failed"), |
| 980 | "expected failure row: {rendered:?}", |
| 981 | ); |
| 982 | assert!( |
| 983 | rendered.contains("upstream 500"), |
| 984 | "expected reason: {rendered:?}", |
| 985 | ); |
| 986 | } |
| 987 | |
| 988 | #[test] |
| 989 | fn render_emits_mode_and_model_when_idle() { |
| 990 | let app = make_app(); |
| 991 | let props = idle_props_for(&app); |
| 992 | let widget = FooterWidget::new(props); |
| 993 | |
| 994 | let area = ratatui::layout::Rect::new(0, 0, 60, 1); |
| 995 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 996 | widget.render(area, &mut buf); |
| 997 | |
| 998 | let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 999 | assert!(rendered.contains("agent")); |
| 1000 | assert!(rendered.contains("deepseek-v4-flash")); |
| 1001 | assert!(!rendered.contains("ready")); |
| 1002 | } |
| 1003 | |
| 1004 | #[test] |
| 1005 | fn working_strip_string_width_matches_request() { |
| 1006 | // The strip must produce exactly `width` characters per frame — |
| 1007 | // otherwise the spacer math in `FooterWidget::render` would |
| 1008 | // mis-align the right-hand chips. Each wave glyph is one cell wide. |
| 1009 | for width in [0usize, 1, 8, 60, 200] { |
| 1010 | let s = super::footer_working_strip_string(width, 7); |
| 1011 | assert_eq!(s.chars().count(), width, "width {width} mismatch"); |
| 1012 | } |
| 1013 | } |
| 1014 | |
| 1015 | #[test] |
| 1016 | fn working_strip_glyph_is_deterministic_per_frame() { |
| 1017 | // Same (col, width, frame) -> same glyph. Frames are raw |
| 1018 | // milliseconds so the strip can move at repaint cadence. |
| 1019 | let a = super::footer_working_strip_string(40, 150); |
| 1020 | let b = super::footer_working_strip_string(40, 150); |
| 1021 | assert_eq!(a, b, "deterministic given the same frame"); |
| 1022 | let c = super::footer_working_strip_string(40, 230); |
| 1023 | assert_ne!(a, c, "advancing one repaint window must change the strip",); |
| 1024 | } |
| 1025 | |
| 1026 | #[test] |
| 1027 | fn working_strip_renders_glyphs_only_when_frame_is_some() { |
| 1028 | // Idle: spacer is plain whitespace. Active: spacer contains the |
| 1029 | // wave animation glyphs and visibly differs from the idle render. |
| 1030 | let app = make_app(); |
| 1031 | let mut props = idle_props_for(&app); |
| 1032 | |
| 1033 | let area = ratatui::layout::Rect::new(0, 0, 80, 1); |
| 1034 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 1035 | FooterWidget::new(props.clone()).render(area, &mut buf); |
| 1036 | let idle: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 1037 | |
| 1038 | props.working_strip_frame = Some(600); |
| 1039 | let mut buf2 = ratatui::buffer::Buffer::empty(area); |
| 1040 | FooterWidget::new(props).render(area, &mut buf2); |
| 1041 | let active: String = (0..area.width).map(|x| buf2[(x, 0)].symbol()).collect(); |
| 1042 | |
| 1043 | assert_ne!( |
| 1044 | idle, active, |
| 1045 | "active footer must visibly differ from idle one" |
| 1046 | ); |
| 1047 | assert!( |
| 1048 | active |
| 1049 | .chars() |
| 1050 | .any(|glyph| super::WAVE_GLYPHS.contains(&glyph)), |
| 1051 | "active strip must contain at least one animation glyph: {active:?}", |
| 1052 | ); |
| 1053 | } |
| 1054 | |
| 1055 | #[test] |
| 1056 | fn working_strip_changes_at_repaint_cadence() { |
| 1057 | let width = 60; |
| 1058 | let f0 = super::footer_working_strip_string(width, 0); |
| 1059 | let f80 = super::footer_working_strip_string(width, 80); |
| 1060 | let changed = f0 |
| 1061 | .chars() |
| 1062 | .zip(f80.chars()) |
| 1063 | .filter(|(before, after)| before != after) |
| 1064 | .count(); |
| 1065 | assert!( |
| 1066 | changed > width / 4, |
| 1067 | "expected the wave to drift across one 80ms repaint; changed {changed}/{width}" |
| 1068 | ); |
| 1069 | } |
| 1070 | |
| 1071 | #[test] |
| 1072 | fn working_strip_renders_multiple_wave_heights() { |
| 1073 | let s = super::footer_working_strip_string(60, 0); |
| 1074 | let mut distinct = Vec::new(); |
| 1075 | for glyph in s.chars() { |
| 1076 | if super::WAVE_GLYPHS.contains(&glyph) && !distinct.contains(&glyph) { |
| 1077 | distinct.push(glyph); |
| 1078 | } |
| 1079 | } |
| 1080 | assert!( |
| 1081 | distinct.len() >= 5, |
| 1082 | "expected several wave heights, saw {distinct:?}", |
| 1083 | ); |
| 1084 | } |
| 1085 | |
| 1086 | #[test] |
| 1087 | fn working_label_pulses_dots_through_full_cycle() { |
| 1088 | // The label sequence `working` → `working.` → `working..` → |
| 1089 | // `working...` then wraps back. Each frame is a discrete tick; |
| 1090 | // the cycle is exactly 4 frames so adjacent ticks visibly differ. |
| 1091 | assert_eq!(super::footer_working_label(0, Locale::En), "working"); |
| 1092 | assert_eq!(super::footer_working_label(1, Locale::En), "working."); |
| 1093 | assert_eq!(super::footer_working_label(2, Locale::En), "working.."); |
| 1094 | assert_eq!(super::footer_working_label(3, Locale::En), "working..."); |
| 1095 | assert_eq!( |
| 1096 | super::footer_working_label(4, Locale::En), |
| 1097 | "working", |
| 1098 | "wraps back at frame 4", |
| 1099 | ); |
| 1100 | assert_eq!(super::footer_working_label(7, Locale::En), "working..."); |
| 1101 | } |
| 1102 | |
| 1103 | /// Render the footer at `width` and return the visible single-line text. |
| 1104 | fn render_at_width(props: FooterProps, width: u16) -> String { |
| 1105 | let area = ratatui::layout::Rect::new(0, 0, width, 1); |
| 1106 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 1107 | FooterWidget::new(props).render(area, &mut buf); |
| 1108 | (0..area.width) |
| 1109 | .map(|x| buf[(x, 0)].symbol()) |
| 1110 | .collect::<String>() |
| 1111 | .trim_end() |
| 1112 | .to_string() |
| 1113 | } |
| 1114 | |
| 1115 | fn props_with_status(state: &str) -> FooterProps { |
| 1116 | let app = make_app(); |
| 1117 | FooterProps::from_app( |
| 1118 | &app, |
| 1119 | None, |
| 1120 | // Production state labels are `&'static str`; for tests we leak a |
| 1121 | // copy to match that lifetime. |
| 1122 | Box::leak(state.to_string().into_boxed_str()), |
| 1123 | palette::DEEPSEEK_SKY, |
| 1124 | Vec::<Span<'static>>::new(), |
| 1125 | Vec::<Span<'static>>::new(), |
| 1126 | Vec::<Span<'static>>::new(), |
| 1127 | Vec::<Span<'static>>::new(), |
| 1128 | Vec::<Span<'static>>::new(), |
| 1129 | ) |
| 1130 | } |
| 1131 | |
| 1132 | /// Issue #88 — at the widest tier the footer shows mode · model · status |
| 1133 | /// without any truncation. |
| 1134 | #[test] |
| 1135 | fn footer_priority_drop_full_at_120_cols() { |
| 1136 | let props = props_with_status("working"); |
| 1137 | let line = render_at_width(props, 120); |
| 1138 | assert!(line.contains("agent"), "mode visible: {line:?}"); |
| 1139 | assert!( |
| 1140 | line.contains("deepseek-v4-flash"), |
| 1141 | "model visible: {line:?}" |
| 1142 | ); |
| 1143 | assert!(line.contains("working"), "status visible: {line:?}"); |
| 1144 | assert!(!line.contains("..."), "no truncation expected: {line:?}"); |
| 1145 | } |
| 1146 | |
| 1147 | #[test] |
| 1148 | fn footer_priority_drop_full_at_100_cols() { |
| 1149 | let props = props_with_status("working"); |
| 1150 | let line = render_at_width(props, 100); |
| 1151 | assert!(line.contains("agent")); |
| 1152 | assert!(line.contains("deepseek-v4-flash")); |
| 1153 | assert!(line.contains("working")); |
| 1154 | } |
| 1155 | |
| 1156 | /// At 80 cols the short status label "working" still fits alongside mode + |
| 1157 | /// model. The line never wraps mid-hint. |
| 1158 | #[test] |
| 1159 | fn footer_priority_drop_full_at_80_cols() { |
| 1160 | let props = props_with_status("working"); |
| 1161 | let line = render_at_width(props, 80); |
| 1162 | assert!(line.contains("agent")); |
| 1163 | assert!(line.contains("deepseek-v4-flash")); |
| 1164 | assert!(!line.contains("..."), "no mid-word truncation: {line:?}"); |
| 1165 | assert!(line.len() <= 80, "fits in 80 cols: {line:?}"); |
| 1166 | } |
| 1167 | |
| 1168 | /// Status drops before the model is truncated. With a longer status label |
| 1169 | /// at 40 cols the status segment is dropped to keep mode + model intact. |
| 1170 | #[test] |
| 1171 | fn footer_priority_drop_status_first_at_40_cols() { |
| 1172 | let props = props_with_status("refreshing context"); |
| 1173 | // "agent · deepseek-v4-flash · refreshing context" = 46 cols. At 40 |
| 1174 | // the status label drops, keeping mode + model verbatim. |
| 1175 | let line = render_at_width(props, 40); |
| 1176 | assert!(line.contains("agent"), "mode kept: {line:?}"); |
| 1177 | assert!( |
| 1178 | line.contains("deepseek-v4-flash"), |
| 1179 | "model kept verbatim: {line:?}" |
| 1180 | ); |
| 1181 | assert!( |
| 1182 | !line.contains("refreshing"), |
| 1183 | "status dropped before model truncated: {line:?}", |
| 1184 | ); |
| 1185 | assert!(line.len() <= 40, "fits in 40 cols: {line:?}"); |
| 1186 | } |
| 1187 | |
| 1188 | /// At 60 cols mode + model + a long status all just fit (49 cols), so the |
| 1189 | /// whole line is preserved. |
| 1190 | #[test] |
| 1191 | fn footer_priority_drop_full_at_60_cols() { |
| 1192 | let props = props_with_status("working"); |
| 1193 | let line = render_at_width(props, 60); |
| 1194 | assert!(line.contains("agent")); |
| 1195 | assert!(line.contains("deepseek-v4-flash")); |
| 1196 | assert!(line.contains("working")); |
| 1197 | } |
| 1198 | |
| 1199 | /// Below 30 cols the model truncates with an ellipsis only after the |
| 1200 | /// status label has already been dropped. Mode label always survives. |
| 1201 | #[test] |
| 1202 | fn footer_priority_drop_truncates_model_only_when_status_already_gone() { |
| 1203 | let props = props_with_status("working"); |
| 1204 | let line = render_at_width(props, 20); |
| 1205 | assert!(line.starts_with("agent"), "mode stays at front: {line:?}"); |
| 1206 | assert!( |
| 1207 | line.contains("..."), |
| 1208 | "model truncated as last resort: {line:?}" |
| 1209 | ); |
| 1210 | assert!(!line.contains("working"), "status dropped: {line:?}"); |
| 1211 | } |
| 1212 | |
| 1213 | fn props_with_status_and_cost(state: &str, cost: &str) -> FooterProps { |
| 1214 | let app = make_app(); |
| 1215 | FooterProps::from_app( |
| 1216 | &app, |
| 1217 | None, |
| 1218 | Box::leak(state.to_string().into_boxed_str()), |
| 1219 | palette::DEEPSEEK_SKY, |
| 1220 | Vec::<Span<'static>>::new(), |
| 1221 | Vec::<Span<'static>>::new(), |
| 1222 | Vec::<Span<'static>>::new(), |
| 1223 | Vec::<Span<'static>>::new(), |
| 1224 | vec![Span::styled(cost.to_string(), Style::default())], |
| 1225 | ) |
| 1226 | } |
| 1227 | |
| 1228 | /// v0.6.6 redesign — cost lives on the LEFT, between model and status. |
| 1229 | /// At wide widths the line reads `mode · model · cost · status`. |
| 1230 | #[test] |
| 1231 | fn footer_cost_renders_in_left_cluster_at_wide_widths() { |
| 1232 | let props = props_with_status_and_cost("working", "$0.42"); |
| 1233 | let line = render_at_width(props, 120); |
| 1234 | let mode_pos = line.find("agent").expect("mode visible"); |
| 1235 | let model_pos = line.find("deepseek-v4-flash").expect("model visible"); |
| 1236 | let cost_pos = line.find("$0.42").expect("cost visible on left"); |
| 1237 | let status_pos = line.find("working").expect("status visible"); |
| 1238 | assert!(mode_pos < model_pos); |
| 1239 | assert!(model_pos < cost_pos, "cost must follow model: {line:?}"); |
| 1240 | assert!(cost_pos < status_pos, "cost must precede status: {line:?}"); |
| 1241 | } |
| 1242 | |
| 1243 | /// Cost is preserved when status drops — cost is steady info, status is |
| 1244 | /// a transient signal. |
| 1245 | #[test] |
| 1246 | fn footer_cost_outranks_status_when_space_tight() { |
| 1247 | // "agent · deepseek-v4-flash · $0.42 · refreshing context" = 53 cols. |
| 1248 | // At 47 the status drops but the cost survives (47 ≥ 36 mode+model+cost). |
| 1249 | let props = props_with_status_and_cost("refreshing context", "$0.42"); |
| 1250 | let line = render_at_width(props, 47); |
| 1251 | assert!(line.contains("agent")); |
| 1252 | assert!(line.contains("deepseek-v4-flash")); |
| 1253 | assert!( |
| 1254 | line.contains("$0.42"), |
| 1255 | "cost survives status drop: {line:?}" |
| 1256 | ); |
| 1257 | assert!(!line.contains("refreshing"), "status dropped: {line:?}"); |
| 1258 | } |
| 1259 | |
| 1260 | #[test] |
| 1261 | fn render_swaps_toast_for_status_line() { |
| 1262 | let app = make_app(); |
| 1263 | let toast = super::FooterToast { |
| 1264 | text: "session saved".to_string(), |
| 1265 | color: Color::Green, |
| 1266 | }; |
| 1267 | let props = FooterProps::from_app( |
| 1268 | &app, |
| 1269 | Some(toast), |
| 1270 | "ready", |
| 1271 | palette::TEXT_MUTED, |
| 1272 | Vec::<Span<'static>>::new(), |
| 1273 | Vec::<Span<'static>>::new(), |
| 1274 | Vec::<Span<'static>>::new(), |
| 1275 | Vec::<Span<'static>>::new(), |
| 1276 | Vec::<Span<'static>>::new(), |
| 1277 | ); |
| 1278 | let widget = FooterWidget::new(props); |
| 1279 | |
| 1280 | let area = ratatui::layout::Rect::new(0, 0, 60, 1); |
| 1281 | let mut buf = ratatui::buffer::Buffer::empty(area); |
| 1282 | widget.render(area, &mut buf); |
| 1283 | |
| 1284 | let rendered: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); |
| 1285 | assert!(rendered.contains("session saved")); |
| 1286 | assert!(!rendered.contains("agent")); |
| 1287 | assert!(!rendered.contains("deepseek-v4-flash")); |
| 1288 | } |
| 1289 | } |
| 1290 |