返回 DeepSeek-TUI-2026
footer.rs
根目录 / crates / tui / src / tui / widgets / footer.rs
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
1290 lines RUST