Skip to main content

zero_tui/widgets/
statusbar.rs

1//! Status bar widget — always visible, single row at the bottom of
2//! the screen (above the prompt).
3//!
4//! Layout, left-to-right (full tier, ≥ 80 cols):
5//!
6//!   `[MODE]  engine:<health>  feed:<age>  dd:<pct>  ops:<LABEL>`
7//!
8//! Plus a `retry:N` addendum when the WS is disconnected and has
9//! already attempted a reconnect.
10//!
11//! Design rules:
12//!
13//! - Mode label left-aligned and colored; never truncated.
14//! - Engine health is one word: OK / RECONNECTING / DOWN.
15//! - Feed age renders as seconds; caution > 3 s, alert > 10 s.
16//! - Drawdown reads `risk.drawdown_pct`. Thresholds:
17//!   - missing → `dd:--` (muted).
18//!   - halted → `dd:HALT` in alert+bold (kill_all or circuit-breaker
19//!     tripped). Hides the number on purpose — "the engine is
20//!     refusing new risk" is the headline; the exact dd figure is a
21//!     detail the `/state` overlay has more room for.
22//!   - 0 .. 2% → primary.
23//!   - 2 .. 5% → caution.
24//!   - > 5% → alert.
25//! - **Operator-state segment is always visible, never hidden**
26//!   (Addendum A §2.3 — the indicator is present even at STEADY so
27//!   the operator never has to guess whether the system is
28//!   watching). Appearance:
29//!   - `ops:?` — classifier has not reported yet (muted).
30//!   - `ops:<LABEL>` — label colored by `ColorHint`.
31//!   - `ops:<LABEL>*` — label is stale (muted asterisk).
32//!
33//! The label is sourced from the engine's `GET /operator/state`
34//! endpoint (ADR-016); the CLI never computes it locally.
35//!
36//! ## Width-responsive tiers
37//!
38//! The status bar picks the widest tier that fits in `area.width`.
39//! Tiers are defined so that each narrower tier is a strict subset
40//! of the tier above, and the ordering is driven by operator-safety
41//! priority — we drop diagnostic segments (retry count, feed age)
42//! before we drop risk segments (drawdown, halt), and we never drop
43//! `ops:` at any width.
44//!
45//! - `Tier::Full` — all segments, double-space separators.
46//! - `Tier::Compact` — drops the `retry:N` addendum and uses
47//!   single-space separators between segments.
48//! - `Tier::Minimal` — renders `[MODE] ops:<LABEL>  dd:<pct>` only.
49//!   The operator's minimum useful floor: which mode you are in,
50//!   what state the engine thinks you are in, and whether capital
51//!   is bleeding.
52//!
53//! ## `rate:` (CLI-side) and `hl:` (Hyperliquid-side)
54//!
55//! The two are parallel by design so an operator reads both with
56//! the same eye movement. Both use a shared "tri-color" policy:
57//!
58//! - headroom ≥ 25 % → primary
59//! - 10 % ≤ headroom < 25 % → caution
60//! - headroom < 10 %, tokens > 0 → alert
61//! - tokens == 0 → `<name>:EXH` in alert+bold
62//!
63//! `rate:N/M` reads from the CLI-side token bucket
64//! ([`zero_engine_client::RateBudget`]) that the caller hands in
65//! each render. `None` → `rate:?` in metadata color (the same
66//! honest-rendering rule `ops:?` uses before the classifier
67//! reports).
68//!
69//! `hl:N/M` reads from `/v2/status.hl_rate` which the engine
70//! optionally reports. Unset → `hl:?`. Once the engine-side cut
71//! surfaces the field (tracked separately from the M2_PLAN row),
72//! this segment starts showing live Hyperliquid pressure without
73//! any CLI code change.
74//!
75//! ## Rendering
76//!
77//! The widget is pure: given a `Mode`, an `EngineState` snapshot, a
78//! `Theme`, and a `now: DateTime<Utc>`, it builds a [`Line`] and
79//! paints it into the buffer. All freshness math flows through the
80//! caller-supplied `now` so snapshot tests can freeze time.
81
82use chrono::{DateTime, Duration, Utc};
83use ratatui::buffer::Buffer;
84use ratatui::layout::Rect;
85use ratatui::style::{Modifier, Style};
86use ratatui::text::{Line, Span};
87use ratatui::widgets::Widget;
88use zero_engine_client::{BudgetSnapshot, EngineState, HlRate, Risk};
89use zero_operator_state::Snapshot as OperatorSnapshot;
90
91use crate::app::mode::Mode;
92use crate::theme::Theme;
93
94/// How old an operator-state snapshot can get before we mark it
95/// stale with a trailing `*` and de-saturate its color. The
96/// classifier polls at ~5 s cadence; 30 s is 6× that, well beyond
97/// "we lost the engine" noise but short enough that the operator
98/// sees the change before a full minute passes.
99const OPERATOR_STATE_STALE_AFTER: Duration = Duration::seconds(30);
100
101/// Width-responsive tier picked by [`StatusBar::render`].
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Tier {
104    /// Every segment, double-space separators.
105    Full,
106    /// Drop `retry:N`; single-space separators.
107    Compact,
108    /// `[MODE] ops:<LABEL>  dd:<pct>` only.
109    Minimal,
110}
111
112#[derive(Debug)]
113pub struct StatusBar<'a> {
114    pub mode: Mode,
115    pub engine: &'a EngineState,
116    pub theme: Theme,
117    /// Wall-clock "now" for freshness math. Callers from live
118    /// render pass `Utc::now()`; tests pass a frozen instant so
119    /// snapshots stay stable.
120    pub now: DateTime<Utc>,
121    /// CLI-side `RateBudget` snapshot, re-read each frame by the
122    /// render pass. `None` means "no bucket attached" (test
123    /// harnesses, early bootstrap) — the widget renders `rate:?`
124    /// in metadata color. The live binary always attaches one
125    /// (`zero::build_client`), so operators see a number from the
126    /// first frame.
127    pub rate_budget: Option<BudgetSnapshot>,
128}
129
130impl Widget for StatusBar<'_> {
131    fn render(self, area: Rect, buf: &mut Buffer) {
132        // Clear the area first so leftover characters from a wider
133        // previous frame don't bleed through a narrower render.
134        for y in area.top()..area.bottom() {
135            for x in area.left()..area.right() {
136                buf[(x, y)].set_char(' ');
137            }
138        }
139
140        let line = self.build_line_for_width(area.width);
141        line.render(area, buf);
142    }
143}
144
145impl StatusBar<'_> {
146    /// Pick the widest tier whose rendered width fits inside
147    /// `available_width`. `Minimal` is the absolute floor and is
148    /// never rejected by the width check — if even Minimal overflows,
149    /// it still renders (and crops naturally at `area.right()` via
150    /// ratatui's `Line::render`), because "mode + ops + dd" at any
151    /// truncation is still more useful than a blank bar.
152    #[must_use]
153    pub fn pick_tier(&self, available_width: u16) -> Tier {
154        for tier in [Tier::Full, Tier::Compact] {
155            let line = self.build_line(tier);
156            if line.width() <= usize::from(available_width) {
157                return tier;
158            }
159        }
160        Tier::Minimal
161    }
162
163    fn build_line_for_width(&self, available_width: u16) -> Line<'static> {
164        let tier = self.pick_tier(available_width);
165        self.build_line(tier)
166    }
167
168    fn build_line(&self, tier: Tier) -> Line<'static> {
169        let mode_span = self.mode_span();
170        let (ops_prefix, ops_label, ops_marker) = self.ops_spans();
171        let dd_span = self.drawdown_span();
172        let sep_wide = Span::styled("  ", Style::default().fg(self.theme.metadata));
173        let sep_narrow = Span::styled(" ", Style::default().fg(self.theme.metadata));
174
175        match tier {
176            Tier::Full => {
177                // Full tier: [MODE] engine:<> [retry:N?]  feed:…  rate:…  hl:…  dd:…  ops:…
178                //
179                // The `rate:` / `hl:` segments sit between `feed:`
180                // and the anchored risk+ops cluster. They are
181                // droppable (next tier elides them) before we
182                // touch `dd:`, `ops:`, or `[MODE]` — those three
183                // are the operator-safety anchors per §2.3.
184                let sep = || sep_wide.clone();
185                let mut spans: Vec<Span<'static>> = vec![mode_span, self.engine_span()];
186                if let Some(retry) = self.retry_span() {
187                    spans.push(retry);
188                }
189                spans.extend([sep(), self.feed_span(), sep(), self.rate_span(), sep()]);
190                // `hl:` is a single Span so we push it directly
191                // (Line::from expects a flat Vec<Span>).
192                spans.push(self.hl_span());
193                spans.extend([sep(), dd_span, sep(), ops_prefix, ops_label, ops_marker]);
194                Line::from(spans)
195            }
196            Tier::Compact => {
197                // Compact drops the `retry:` addendum and the
198                // `rate:` / `hl:` diagnostic pair to make room
199                // for the anchored segments. Single-space seps.
200                let sep = || sep_narrow.clone();
201                Line::from(vec![
202                    mode_span,
203                    self.engine_span(),
204                    sep(),
205                    self.feed_span(),
206                    sep(),
207                    dd_span,
208                    sep(),
209                    ops_prefix,
210                    ops_label,
211                    ops_marker,
212                ])
213            }
214            Tier::Minimal => Line::from(vec![
215                mode_span, ops_prefix, ops_label, ops_marker, sep_narrow, dd_span,
216            ]),
217        }
218    }
219
220    fn mode_span(&self) -> Span<'static> {
221        // Leading space is intentional — the bar abuts the prompt
222        // and a 1-col left margin lets the bracketed mode breathe
223        // without looking glued to the screen edge.
224        Span::styled(
225            format!(" [{}] ", self.mode.short()),
226            Style::default()
227                .fg(self.theme.primary)
228                .add_modifier(Modifier::BOLD),
229        )
230    }
231
232    fn engine_span(&self) -> Span<'static> {
233        let (label, color) = if self.engine.connection.ws_connected {
234            ("OK", self.theme.primary)
235        } else if self.engine.connection.total_attempts > 0 {
236            ("RECONNECTING", self.theme.caution)
237        } else {
238            ("DOWN", self.theme.alert)
239        };
240        Span::styled(format!("engine:{label}"), Style::default().fg(color))
241    }
242
243    /// Retry addendum, present only while disconnected with at
244    /// least one prior attempt. Intentionally *not* separated from
245    /// `engine:` by the outer separator — the two belong together
246    /// as a single "connection story" in the reader's eye.
247    fn retry_span(&self) -> Option<Span<'static>> {
248        if !self.engine.connection.ws_connected && self.engine.connection.reconnect_count > 0 {
249            Some(Span::styled(
250                format!(" retry:{}", self.engine.connection.reconnect_count),
251                Style::default().fg(self.theme.caution),
252            ))
253        } else {
254            None
255        }
256    }
257
258    fn feed_span(&self) -> Span<'static> {
259        match self.engine.feed_age_seconds(self.now) {
260            None => Span::styled("feed:--", Style::default().fg(self.theme.metadata)),
261            Some(age) => {
262                let color = if age < 0 {
263                    // Clock skew between CLI and engine host: show
264                    // the number but don't alarm on it.
265                    self.theme.metadata
266                } else if age <= 3 {
267                    self.theme.primary
268                } else if age <= 10 {
269                    self.theme.caution
270                } else {
271                    self.theme.alert
272                };
273                Span::styled(format!("feed:{age}s"), Style::default().fg(color))
274            }
275        }
276    }
277
278    /// Build the drawdown segment. See module-level doc for the
279    /// threshold table. `kill_all` / `circuit_breaker_active` take
280    /// precedence over the number because "no new risk" matters
281    /// more than "how deep we are".
282    fn drawdown_span(&self) -> Span<'static> {
283        match self.engine.risk.as_ref() {
284            None => Span::styled("dd:--", Style::default().fg(self.theme.metadata)),
285            Some(stat) => render_drawdown(&stat.value, &self.theme),
286        }
287    }
288
289    /// CLI-side rate bucket segment. Rendering contract, in
290    /// priority order:
291    ///
292    /// 1. `None` → `rate:?` in metadata color. No bucket was
293    ///    handed in; don't lie about the state.
294    /// 2. `capacity == 0` → `rate:?`. Defensive: a zero-capacity
295    ///    snapshot would divide-by-zero on `headroom()`; treat
296    ///    it as "unknown" and show the honest placeholder.
297    /// 3. `tokens == 0` → `rate:EXH` in alert+bold. The next
298    ///    request *will* be refused locally; the operator needs
299    ///    to see that plainly.
300    /// 4. Otherwise `rate:N/M` colored by `headroom()` thresholds.
301    fn rate_span(&self) -> Span<'static> {
302        let prefix = "rate:";
303        let Some(snap) = self.rate_budget else {
304            return Span::styled(
305                format!("{prefix}?"),
306                Style::default().fg(self.theme.metadata),
307            );
308        };
309        if snap.capacity == 0 {
310            return Span::styled(
311                format!("{prefix}?"),
312                Style::default().fg(self.theme.metadata),
313            );
314        }
315        if snap.tokens == 0 {
316            return Span::styled(
317                format!("{prefix}EXH"),
318                Style::default()
319                    .fg(self.theme.alert)
320                    .add_modifier(Modifier::BOLD),
321            );
322        }
323        Span::styled(
324            format!("{prefix}{}/{}", snap.tokens, snap.capacity),
325            Style::default().fg(self.pressure_color(snap.headroom())),
326        )
327    }
328
329    /// Hyperliquid rate segment sourced from `/v2/status.hl_rate`.
330    /// Mirrors `rate_span`'s tri-color policy so both segments
331    /// share the same visual language.
332    fn hl_span(&self) -> Span<'static> {
333        let prefix = "hl:";
334        let Some(HlRate { used, cap }) = self.engine.hl_rate_snapshot() else {
335            return Span::styled(
336                format!("{prefix}?"),
337                Style::default().fg(self.theme.metadata),
338            );
339        };
340        if cap == 0 {
341            return Span::styled(
342                format!("{prefix}?"),
343                Style::default().fg(self.theme.metadata),
344            );
345        }
346        // `used` can legitimately exceed `cap` during immune
347        // bypass overshoot (engine/shared/http.py:_hl_check_global_rate
348        // keeps counting immune calls past the cap). Treat any
349        // `used >= cap` as EXH — the operator needs to see "we
350        // are at or past the HL cap", not the exact overshoot.
351        if used >= cap {
352            return Span::styled(
353                format!("{prefix}EXH"),
354                Style::default()
355                    .fg(self.theme.alert)
356                    .add_modifier(Modifier::BOLD),
357            );
358        }
359        // Headroom math mirrors BudgetSnapshot::headroom.
360        let headroom = f64::from(cap.saturating_sub(used)) / f64::from(cap);
361        Span::styled(
362            format!("{prefix}{used}/{cap}"),
363            Style::default().fg(self.pressure_color(headroom)),
364        )
365    }
366
367    /// Shared tri-color threshold for `rate:` and `hl:`:
368    /// primary ≥ 25 %, caution 10..25 %, alert < 10 %.
369    fn pressure_color(&self, headroom: f64) -> ratatui::style::Color {
370        if headroom >= 0.25 {
371            self.theme.primary
372        } else if headroom >= 0.10 {
373            self.theme.caution
374        } else {
375            self.theme.alert
376        }
377    }
378
379    fn ops_spans(&self) -> (Span<'static>, Span<'static>, Span<'static>) {
380        let metadata = self.theme.metadata;
381        let prefix = Span::styled("ops:", Style::default().fg(metadata));
382        match &self.engine.operator_state {
383            None => (
384                prefix,
385                Span::styled("?", Style::default().fg(metadata)),
386                Span::raw(""),
387            ),
388            Some(stat) => {
389                let snap: &OperatorSnapshot = &stat.value;
390                let color = self.theme.resolve_hint(snap.label.color_hint());
391                let stale = stat.is_stale(self.now, OPERATOR_STATE_STALE_AFTER);
392                let label_color = if stale { metadata } else { color };
393                let label_span = Span::styled(
394                    snap.label.short().to_string(),
395                    Style::default()
396                        .fg(label_color)
397                        .add_modifier(Modifier::BOLD),
398                );
399                let marker_span = if stale {
400                    Span::styled("*", Style::default().fg(metadata))
401                } else {
402                    Span::raw("")
403                };
404                (prefix, label_span, marker_span)
405            }
406        }
407    }
408}
409
410fn render_drawdown(risk: &Risk, theme: &Theme) -> Span<'static> {
411    if risk.is_halted() {
412        return Span::styled(
413            "dd:HALT",
414            Style::default()
415                .fg(theme.alert)
416                .add_modifier(Modifier::BOLD),
417        );
418    }
419    match risk.drawdown_pct {
420        None => Span::styled("dd:--", Style::default().fg(theme.metadata)),
421        Some(pct) => {
422            // `pct` is engine-side magnitude, always ≥ 0 in normal
423            // operation. Guard against stray negative values by
424            // treating them as 0 for color selection — honesty
425            // matters more than color correctness on malformed data.
426            let magnitude = pct.max(0.0);
427            let color = if magnitude <= 2.0 {
428                theme.primary
429            } else if magnitude <= 5.0 {
430                theme.caution
431            } else {
432                theme.alert
433            };
434            Span::styled(format!("dd:{pct:.1}%"), Style::default().fg(color))
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use chrono::TimeZone;
443    use ratatui::Terminal;
444    use ratatui::backend::TestBackend;
445    use zero_engine_client::{Source, Stat, V2Status};
446    use zero_operator_state::{Label, StateVector};
447
448    fn snapshot_at(label: Label, as_of: DateTime<Utc>) -> Stat<OperatorSnapshot> {
449        let snap = OperatorSnapshot::new(label, StateVector::default(), as_of, 1);
450        Stat::new(snap, Source::Http).with_as_of(as_of)
451    }
452
453    fn risk_stat(risk: Risk, as_of: DateTime<Utc>) -> Stat<Risk> {
454        Stat::new(risk, Source::Ws).with_as_of(as_of)
455    }
456
457    fn render_bar_at(engine: &EngineState, now: DateTime<Utc>, width: u16) -> Vec<String> {
458        let backend = TestBackend::new(width, 1);
459        let mut term = Terminal::new(backend).expect("terminal");
460        term.draw(|f| {
461            let bar = StatusBar {
462                mode: Mode::Conversation,
463                engine,
464                theme: Theme::default(),
465                now,
466                rate_budget: None,
467            };
468            f.render_widget(bar, f.area());
469        })
470        .expect("draw");
471        let buf = term.backend().buffer().clone();
472        (0..buf.area.height)
473            .map(|y| {
474                (0..buf.area.width)
475                    .map(|x| buf[(x, y)].symbol().to_string())
476                    .collect::<String>()
477            })
478            .collect()
479    }
480
481    fn render_bar(engine: &EngineState, now: DateTime<Utc>) -> Vec<String> {
482        render_bar_at(engine, now, 80)
483    }
484
485    #[test]
486    fn unseen_snapshot_renders_question_mark() {
487        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
488        let engine = EngineState::new();
489        let lines = render_bar(&engine, now);
490        assert!(
491            lines[0].contains("ops:?"),
492            "expected ops:? placeholder, got {lines:?}"
493        );
494    }
495
496    #[test]
497    fn fresh_snapshot_renders_label() {
498        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
499        let mut engine = EngineState::new();
500        engine.operator_state = Some(snapshot_at(Label::Steady, now));
501        let lines = render_bar(&engine, now);
502        assert!(
503            lines[0].contains("ops:STEADY"),
504            "expected ops:STEADY, got {lines:?}"
505        );
506        assert!(
507            !lines[0].contains("STEADY*"),
508            "fresh snapshot should not carry staleness marker: {lines:?}"
509        );
510    }
511
512    #[test]
513    fn stale_snapshot_gets_asterisk() {
514        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
515        let now = as_of + Duration::seconds(60);
516        let mut engine = EngineState::new();
517        engine.operator_state = Some(snapshot_at(Label::Tilt, as_of));
518        let lines = render_bar(&engine, now);
519        assert!(
520            lines[0].contains("ops:TILT*"),
521            "stale TILT should render with asterisk: {lines:?}"
522        );
523    }
524
525    #[test]
526    fn every_label_has_a_rendered_form() {
527        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
528        for (label, expected) in [
529            (Label::Fresh, "ops:FRESH"),
530            (Label::Steady, "ops:STEADY"),
531            (Label::Elevated, "ops:ELEVATED"),
532            (Label::Tilt, "ops:TILT"),
533            (Label::Fatigued, "ops:FATIGUED"),
534            (Label::Recovery, "ops:RECOVERY"),
535        ] {
536            let mut engine = EngineState::new();
537            engine.operator_state = Some(snapshot_at(label, now));
538            let lines = render_bar(&engine, now);
539            assert!(
540                lines[0].contains(expected),
541                "label {label:?} should render as {expected}, got {lines:?}"
542            );
543        }
544    }
545
546    #[test]
547    fn drawdown_missing_shows_placeholder() {
548        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
549        let engine = EngineState::new();
550        let lines = render_bar(&engine, now);
551        assert!(lines[0].contains("dd:--"), "got {lines:?}");
552    }
553
554    #[test]
555    fn drawdown_renders_one_decimal_percent() {
556        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
557        let mut engine = EngineState::new();
558        let risk = Risk {
559            drawdown_pct: Some(1.23),
560            ..Default::default()
561        };
562        engine.risk = Some(risk_stat(risk, now));
563        let lines = render_bar(&engine, now);
564        assert!(lines[0].contains("dd:1.2%"), "got {lines:?}");
565    }
566
567    #[test]
568    fn drawdown_halted_reads_halt() {
569        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
570        let mut engine = EngineState::new();
571        let risk = Risk {
572            drawdown_pct: Some(0.5),
573            halted: true,
574            ..Default::default()
575        };
576        engine.risk = Some(risk_stat(risk, now));
577        let lines = render_bar(&engine, now);
578        assert!(
579            lines[0].contains("dd:HALT"),
580            "halt must override the number: {lines:?}"
581        );
582        assert!(
583            !lines[0].contains("dd:0.5%"),
584            "number must not leak when halted: {lines:?}"
585        );
586    }
587
588    #[test]
589    fn drawdown_circuit_breaker_reads_halt() {
590        // Any halt-family flag flips to HALT — not just `halted`.
591        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
592        let mut engine = EngineState::new();
593        let risk = Risk {
594            drawdown_pct: Some(3.0),
595            global_halt: true,
596            ..Default::default()
597        };
598        engine.risk = Some(risk_stat(risk, now));
599        let lines = render_bar(&engine, now);
600        assert!(lines[0].contains("dd:HALT"), "got {lines:?}");
601    }
602
603    #[test]
604    fn minimal_tier_drops_engine_and_feed() {
605        // 40 cols forces Minimal. `[CONV] ops:STEADY  dd:1.0%` fits
606        // inside that. The important contract is: `engine:` and
607        // `feed:` are gone, but `ops:` and `dd:` survive.
608        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
609        let mut engine = EngineState::new();
610        engine.operator_state = Some(snapshot_at(Label::Steady, now));
611        engine.risk = Some(risk_stat(
612            Risk {
613                drawdown_pct: Some(1.0),
614                ..Default::default()
615            },
616            now,
617        ));
618
619        let lines = render_bar_at(&engine, now, 40);
620        assert!(lines[0].contains("ops:STEADY"), "got {lines:?}");
621        assert!(lines[0].contains("dd:1.0%"), "got {lines:?}");
622        assert!(
623            !lines[0].contains("engine:"),
624            "minimal drops engine: {lines:?}"
625        );
626        assert!(!lines[0].contains("feed:"), "minimal drops feed: {lines:?}");
627    }
628
629    #[test]
630    fn full_tier_includes_all_segments_at_120_cols() {
631        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
632        let mut engine = EngineState::new();
633        engine.operator_state = Some(snapshot_at(Label::Elevated, now));
634        engine.risk = Some(risk_stat(
635            Risk {
636                drawdown_pct: Some(2.5),
637                ..Default::default()
638            },
639            now,
640        ));
641        engine.apply_status(V2Status::default(), now, Source::Ws);
642        engine.on_ws_connected();
643
644        let lines = render_bar_at(&engine, now, 120);
645        for needle in [" [CONV]", "engine:OK", "feed:0s", "dd:2.5%", "ops:ELEVATED"] {
646            assert!(
647                lines[0].contains(needle),
648                "full tier missing {needle}: {lines:?}"
649            );
650        }
651    }
652
653    #[test]
654    fn pick_tier_prefers_widest_fit() {
655        // Freeze a scenario, ask pick_tier at three widths, expect
656        // strictly monotonic narrowing as the budget shrinks.
657        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
658        let mut engine = EngineState::new();
659        engine.operator_state = Some(snapshot_at(Label::Elevated, now));
660        engine.risk = Some(risk_stat(
661            Risk {
662                drawdown_pct: Some(3.3),
663                ..Default::default()
664            },
665            now,
666        ));
667        engine.on_ws_connected();
668
669        let make_bar = |w: u16| {
670            let bar = StatusBar {
671                mode: Mode::Conversation,
672                engine: &engine,
673                theme: Theme::default(),
674                now,
675                rate_budget: None,
676            };
677            bar.pick_tier(w)
678        };
679
680        assert_eq!(make_bar(200), Tier::Full);
681        assert_eq!(make_bar(80), Tier::Full);
682        // 30 cols is well below both Full and Compact widths.
683        assert_eq!(make_bar(30), Tier::Minimal);
684    }
685
686    // ── rate: / hl: unit coverage (M2 §2) ─────────────────────
687    //
688    // The fault-matrix integration suite pins the full-line
689    // rendering at every canonical width; these tests pin the
690    // *per-segment* contract in isolation so a color-threshold
691    // regression surfaces here first (smaller diff, faster
692    // iteration).
693
694    fn bar_with_budget(snap: BudgetSnapshot) -> StatusBar<'static> {
695        // The widget is `'static` here because the engine is a
696        // leaked default — tests only call `build_line(...)`
697        // which reads no `'a` lifetime-tied data beyond
698        // `theme`+`rate_budget`, both owned.
699        let engine: &'static EngineState = Box::leak(Box::new(EngineState::new()));
700        StatusBar {
701            mode: Mode::Conversation,
702            engine,
703            theme: Theme::default(),
704            now: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
705            rate_budget: Some(snap),
706        }
707    }
708
709    #[test]
710    fn rate_segment_is_question_mark_without_bucket() {
711        let engine = EngineState::new();
712        let bar = StatusBar {
713            mode: Mode::Conversation,
714            engine: &engine,
715            theme: Theme::default(),
716            now: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
717            rate_budget: None,
718        };
719        let span = bar.rate_span();
720        assert_eq!(span.content, "rate:?");
721    }
722
723    #[test]
724    fn rate_segment_renders_n_over_m() {
725        let bar = bar_with_budget(BudgetSnapshot {
726            capacity: 60,
727            refill_per_second: 1.0,
728            tokens: 42,
729        });
730        assert_eq!(bar.rate_span().content, "rate:42/60");
731    }
732
733    #[test]
734    fn rate_segment_renders_exh_at_zero_tokens() {
735        let bar = bar_with_budget(BudgetSnapshot {
736            capacity: 60,
737            refill_per_second: 1.0,
738            tokens: 0,
739        });
740        let span = bar.rate_span();
741        assert_eq!(span.content, "rate:EXH");
742        assert!(
743            span.style.add_modifier.contains(Modifier::BOLD),
744            "rate:EXH must render bold for at-a-glance operator visibility",
745        );
746    }
747
748    #[test]
749    fn rate_segment_zero_capacity_renders_question_mark() {
750        // Defensive: a bucket with capacity 0 would divide-by-
751        // zero on headroom; treat it as unknown rather than EXH
752        // because a zero-capacity bucket is a config bug and
753        // `?` is the honest render for "we don't know".
754        let bar = bar_with_budget(BudgetSnapshot {
755            capacity: 0,
756            refill_per_second: 0.0,
757            tokens: 0,
758        });
759        assert_eq!(bar.rate_span().content, "rate:?");
760    }
761
762    #[test]
763    fn rate_segment_color_bands_cover_all_headroom_regions() {
764        let theme = Theme::default();
765        let mk = |tokens: u32| {
766            bar_with_budget(BudgetSnapshot {
767                capacity: 60,
768                refill_per_second: 1.0,
769                tokens,
770            })
771            .rate_span()
772            .style
773            .fg
774            .unwrap()
775        };
776        // 60 → 100 % headroom → primary
777        assert_eq!(mk(60), theme.primary);
778        // 15 / 60 = 25 % → primary (≥25 % band's lower edge)
779        assert_eq!(mk(15), theme.primary);
780        // 14 / 60 ≈ 23 % → caution
781        assert_eq!(mk(14), theme.caution);
782        // 7 / 60 ≈ 11 % → caution (still ≥ 10 %)
783        assert_eq!(mk(7), theme.caution);
784        // 5 / 60 ≈ 8 % → alert
785        assert_eq!(mk(5), theme.alert);
786        // 1 / 60 ≈ 1.7 % → alert
787        assert_eq!(mk(1), theme.alert);
788    }
789
790    #[test]
791    fn hl_segment_is_question_mark_when_engine_silent() {
792        let engine = EngineState::new();
793        let bar = StatusBar {
794            mode: Mode::Conversation,
795            engine: &engine,
796            theme: Theme::default(),
797            now: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
798            rate_budget: None,
799        };
800        assert_eq!(bar.hl_span().content, "hl:?");
801    }
802
803    #[test]
804    fn hl_segment_renders_used_over_cap_from_v2status() {
805        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
806        let mut engine = EngineState::new();
807        engine.apply_status(
808            V2Status {
809                hl_rate: Some(zero_engine_client::HlRate {
810                    used: 120,
811                    cap: 240,
812                }),
813                ..V2Status::default()
814            },
815            now,
816            Source::Ws,
817        );
818        let bar = StatusBar {
819            mode: Mode::Conversation,
820            engine: &engine,
821            theme: Theme::default(),
822            now,
823            rate_budget: None,
824        };
825        assert_eq!(bar.hl_span().content, "hl:120/240");
826    }
827
828    #[test]
829    fn hl_segment_overshoot_renders_exh() {
830        // `used >= cap` is the immune-bypass overshoot signal;
831        // the widget renders `hl:EXH` rather than, say,
832        // `hl:245/240` because operators need a single-glance
833        // "we are at or past the wall", not a bookkeeping line.
834        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
835        let mut engine = EngineState::new();
836        engine.apply_status(
837            V2Status {
838                hl_rate: Some(zero_engine_client::HlRate {
839                    used: 245,
840                    cap: 240,
841                }),
842                ..V2Status::default()
843            },
844            now,
845            Source::Ws,
846        );
847        let bar = StatusBar {
848            mode: Mode::Conversation,
849            engine: &engine,
850            theme: Theme::default(),
851            now,
852            rate_budget: None,
853        };
854        let span = bar.hl_span();
855        assert_eq!(span.content, "hl:EXH");
856        assert!(span.style.add_modifier.contains(Modifier::BOLD));
857    }
858
859    #[test]
860    fn narrow_render_never_wraps_or_panics() {
861        // Regression: rendering a 20-column bar used to panic when
862        // the minimal line was wider than the area. Now we just
863        // crop at area.right() via ratatui and keep going.
864        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
865        let mut engine = EngineState::new();
866        engine.operator_state = Some(snapshot_at(Label::Tilt, now));
867        let lines = render_bar_at(&engine, now, 20);
868        assert_eq!(lines.len(), 1, "status bar is single-row: {lines:?}");
869        assert_eq!(lines[0].chars().count(), 20);
870    }
871}