Skip to main content

zero_tui/widgets/
overlay.rs

1//! Full-screen-ish modal overlays painted on top of the mode pane.
2//!
3//! The only overlay in M1 is [`StateOverlay`] — the operator-state
4//! overview triggered by `/state`. It is sourced from the engine's
5//! `operator_state` mirror (ADR-016); the CLI never computes the
6//! label locally. When the mirror is unpopulated the overlay says
7//! so, honestly, rather than inventing a default.
8//!
9//! Design constraints (Addendum A §2.3 / §2.4):
10//! - **Descriptive, not judgmental.** No emoji, no "you're doing
11//!   great", no "calm down." The label + vector speak for
12//!   themselves.
13//! - **Shows its work.** Every classifier input (velocity,
14//!   deviation, session, loss-reaction, re-entry, sleep-proxy) is
15//!   printed, so the operator can see *why* a label is what it is.
16//! - **One-key dismiss.** No buttons. Any key press closes the
17//!   overlay; Ctrl+C still exits the terminal. See `input.rs`.
18
19use std::time::Instant;
20
21use chrono::{DateTime, Utc};
22use ratatui::buffer::Buffer;
23use ratatui::layout::Rect;
24use ratatui::style::{Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::{Block, Borders, Clear, Widget};
27use zero_engine_client::{EngineState, Evaluation};
28use zero_operator_state::Snapshot as OperatorSnapshot;
29use zero_operator_state::friction::FrictionLevel;
30
31use crate::app::state::FrictionPause;
32use crate::theme::Theme;
33use crate::widgets::verdict::VerdictBlock;
34
35/// The `/state` overlay. See module docs.
36#[derive(Debug)]
37pub struct StateOverlay<'a> {
38    pub engine: &'a EngineState,
39    pub theme: Theme,
40    /// Wall-clock "now" used for snapshot-age arithmetic. Tests pass
41    /// a frozen instant for determinism.
42    pub now: DateTime<Utc>,
43}
44
45/// Preferred minimum width/height. If the terminal is smaller we
46/// clamp to whatever is available and the widget still paints
47/// without panicking; it just loses some fields.
48const PREFERRED_WIDTH: u16 = 64;
49const PREFERRED_HEIGHT: u16 = 18;
50
51impl Widget for StateOverlay<'_> {
52    fn render(self, area: Rect, buf: &mut Buffer) {
53        let rect = centered(area, PREFERRED_WIDTH, PREFERRED_HEIGHT);
54        // Clear the area under the overlay so the mode pane's glyphs
55        // don't bleed through at cells we don't rewrite.
56        Clear.render(rect, buf);
57
58        let block = Block::default()
59            .borders(Borders::ALL)
60            .title(Line::from(vec![Span::styled(
61                " operator state ",
62                Style::default()
63                    .fg(self.theme.primary)
64                    .add_modifier(Modifier::BOLD),
65            )]))
66            .border_style(Style::default().fg(self.theme.metadata));
67
68        let inner = block.inner(rect);
69        block.render(rect, buf);
70
71        match &self.engine.operator_state {
72            None => render_unseen(inner, buf, self.theme),
73            Some(stat) => {
74                render_snapshot(inner, buf, self.theme, &stat.value, stat.as_of, self.now);
75            }
76        }
77    }
78}
79
80fn render_unseen(area: Rect, buf: &mut Buffer, theme: Theme) {
81    let mut y = area.top();
82    let put = |buf: &mut Buffer, y: &mut u16, spans: Vec<Span<'_>>| {
83        if *y < area.bottom() {
84            let line = Line::from(spans);
85            let r = Rect {
86                x: area.x,
87                y: *y,
88                width: area.width,
89                height: 1,
90            };
91            line.render(r, buf);
92            *y = y.saturating_add(1);
93        }
94    };
95    put(
96        buf,
97        &mut y,
98        vec![Span::styled(
99            "engine has not reported operator state yet",
100            Style::default().fg(theme.metadata),
101        )],
102    );
103    y = y.saturating_add(1);
104    put(
105        buf,
106        &mut y,
107        vec![Span::styled(
108            "→ ensure the engine is running with ADR-016 enabled,",
109            Style::default().fg(theme.metadata),
110        )],
111    );
112    put(
113        buf,
114        &mut y,
115        vec![Span::styled(
116            "  then reopen this overlay with /state",
117            Style::default().fg(theme.metadata),
118        )],
119    );
120    put_close_hint(buf, area, theme);
121}
122
123#[allow(clippy::too_many_lines)]
124fn render_snapshot(
125    area: Rect,
126    buf: &mut Buffer,
127    theme: Theme,
128    snap: &OperatorSnapshot,
129    as_of: DateTime<Utc>,
130    now: DateTime<Utc>,
131) {
132    let mut y = area.top();
133    let width = area.width;
134
135    // ── Big label + friction ───────────────────────────────────────
136    let label_color = theme.resolve_hint(snap.label.color_hint());
137    let age_secs = (now - as_of).num_seconds().max(0);
138    let age_str = format_age(age_secs);
139
140    draw_line(
141        buf,
142        area,
143        &mut y,
144        width,
145        vec![
146            Span::styled("label  ", Style::default().fg(theme.metadata)),
147            Span::styled(
148                snap.label.short().to_string(),
149                Style::default()
150                    .fg(label_color)
151                    .add_modifier(Modifier::BOLD),
152            ),
153            Span::styled("    ", Style::default()),
154            Span::styled("friction ", Style::default().fg(theme.metadata)),
155            Span::styled(
156                format!("{:?}", snap.friction),
157                Style::default().fg(theme.primary),
158            ),
159            Span::styled("    ", Style::default()),
160            Span::styled("as-of ", Style::default().fg(theme.metadata)),
161            Span::styled(age_str, Style::default().fg(theme.metadata)),
162        ],
163    );
164
165    y = y.saturating_add(1);
166
167    // ── State vector components ────────────────────────────────────
168    draw_header(buf, area, &mut y, width, theme, "state vector");
169
170    let v = &snap.vector;
171
172    // Velocity row
173    let baseline = v
174        .velocity
175        .baseline_1h
176        .map_or("—".into(), |b| format!("{b:.1}/h"));
177    draw_kv(
178        buf,
179        area,
180        &mut y,
181        width,
182        theme,
183        "velocity",
184        &format!(
185            "1h={}  4h={}  24h={}   baseline={}",
186            v.velocity.last_1h, v.velocity.last_4h, v.velocity.last_24h, baseline
187        ),
188    );
189
190    // Deviation
191    let dev_10 = if v.deviation.verdicts_last_10 == 0 {
192        "—".into()
193    } else {
194        format!(
195            "{}/{} ({:.0}%)",
196            v.deviation.overrides_last_10,
197            v.deviation.verdicts_last_10,
198            100.0 * v.deviation.rate_last_10(),
199        )
200    };
201    draw_kv(
202        buf,
203        area,
204        &mut y,
205        width,
206        theme,
207        "deviation",
208        &format!(
209            "last-10={}   last-50={}/{}",
210            dev_10, v.deviation.overrides_last_50, v.deviation.verdicts_last_50,
211        ),
212    );
213
214    // Session
215    let session_ms = v.session.active_duration_ms;
216    let focus_ms = v.session.longest_focus_ms;
217    let since_break_ms = v.session.since_last_break_ms;
218    draw_kv(
219        buf,
220        area,
221        &mut y,
222        width,
223        theme,
224        "session",
225        &format!(
226            "active={}  longest-focus={}  since-break={}",
227            format_ms(session_ms),
228            format_ms(focus_ms),
229            format_ms(since_break_ms),
230        ),
231    );
232
233    // Loss reaction
234    let lr_baseline = v.loss_reaction.baseline_ms.map_or("—".into(), format_ms);
235    draw_kv(
236        buf,
237        area,
238        &mut y,
239        width,
240        theme,
241        "loss-reac",
242        &format!(
243            "median-10={}  fastest-session={}  baseline={}",
244            format_ms(v.loss_reaction.median_last_10_ms),
245            format_ms(v.loss_reaction.fastest_session_ms),
246            lr_baseline,
247        ),
248    );
249
250    // Re-entry
251    draw_kv(
252        buf,
253        area,
254        &mut y,
255        width,
256        theme,
257        "re-entry",
258        &format!(
259            "15m={}  30m={}  2h={}",
260            v.re_entry.within_15m, v.re_entry.within_30m, v.re_entry.within_2h,
261        ),
262    );
263
264    // Sleep proxy + break flag
265    let sleep = v
266        .sleep_proxy
267        .hours_since_rest_ended
268        .map_or("—".into(), |h| format!("{h}h"));
269    let on_break = if v.on_break { "yes" } else { "no" };
270    draw_kv(
271        buf,
272        area,
273        &mut y,
274        width,
275        theme,
276        "sleep",
277        &format!("hours-since-rest={sleep}   on-break={on_break}"),
278    );
279
280    put_close_hint(buf, area, theme);
281}
282
283fn draw_line(buf: &mut Buffer, area: Rect, y: &mut u16, width: u16, spans: Vec<Span<'_>>) {
284    if *y >= area.bottom() {
285        return;
286    }
287    let r = Rect {
288        x: area.x,
289        y: *y,
290        width,
291        height: 1,
292    };
293    Line::from(spans).render(r, buf);
294    *y = y.saturating_add(1);
295}
296
297fn draw_header(buf: &mut Buffer, area: Rect, y: &mut u16, width: u16, theme: Theme, text: &str) {
298    draw_line(
299        buf,
300        area,
301        y,
302        width,
303        vec![Span::styled(
304            text.to_string(),
305            Style::default()
306                .fg(theme.primary)
307                .add_modifier(Modifier::BOLD),
308        )],
309    );
310}
311
312fn draw_kv(
313    buf: &mut Buffer,
314    area: Rect,
315    y: &mut u16,
316    width: u16,
317    theme: Theme,
318    key: &str,
319    value: &str,
320) {
321    draw_line(
322        buf,
323        area,
324        y,
325        width,
326        vec![
327            Span::styled(format!("  {key:<10} "), Style::default().fg(theme.metadata)),
328            Span::styled(value.to_string(), Style::default().fg(theme.primary)),
329        ],
330    );
331}
332
333fn put_close_hint(buf: &mut Buffer, area: Rect, theme: Theme) {
334    if area.height == 0 {
335        return;
336    }
337    let r = Rect {
338        x: area.x,
339        // Pin to the last row of the inner rect so the hint is
340        // always visible regardless of how tall the vector block
341        // grew.
342        y: area.bottom().saturating_sub(1),
343        width: area.width,
344        height: 1,
345    };
346    Line::from(vec![Span::styled(
347        "press any key to close",
348        Style::default()
349            .fg(theme.metadata)
350            .add_modifier(Modifier::DIM),
351    )])
352    .render(r, buf);
353}
354
355/// Center a rect of preferred size inside `area`, clamping to the
356/// available space. A terminal smaller than the preferred size just
357/// uses the whole area.
358fn centered(area: Rect, width: u16, height: u16) -> Rect {
359    let w = width.min(area.width);
360    let h = height.min(area.height);
361    let x = area.x + (area.width.saturating_sub(w)) / 2;
362    let y = area.y + (area.height.saturating_sub(h)) / 2;
363    Rect {
364        x,
365        y,
366        width: w,
367        height: h,
368    }
369}
370
371fn format_age(secs: i64) -> String {
372    if secs < 60 {
373        format!("{secs}s ago")
374    } else if secs < 3600 {
375        format!("{}m{}s ago", secs / 60, secs % 60)
376    } else {
377        format!("{}h ago", secs / 3600)
378    }
379}
380
381/// Friction-pause overlay — renders a visible countdown (L1) or a
382/// countdown-plus-typed-confirm field (L2+). The overlay is read
383/// from [`FrictionPause`]; completion is the event loop's job,
384/// not the widget's.
385///
386/// Why the widget takes an `Instant` by argument instead of
387/// calling `Instant::now()` itself: tests. We want deterministic
388/// rendering at fractional countdown states.
389#[derive(Debug)]
390pub struct FrictionPauseOverlay<'a> {
391    pub pause: &'a FrictionPause,
392    pub theme: Theme,
393    pub now: Instant,
394}
395
396/// Preferred size for the friction overlay. Narrower than the
397/// state overlay — this is a gate, not a data dump.
398const FRICTION_PREFERRED_WIDTH: u16 = 56;
399const FRICTION_PREFERRED_HEIGHT: u16 = 11;
400
401impl Widget for FrictionPauseOverlay<'_> {
402    fn render(self, area: Rect, buf: &mut Buffer) {
403        let rect = centered(area, FRICTION_PREFERRED_WIDTH, FRICTION_PREFERRED_HEIGHT);
404        Clear.render(rect, buf);
405
406        // Border color tracks severity — amber at L1, alert at L2+.
407        let border_color = match self.pause.level {
408            FrictionLevel::L0 => self.theme.metadata,
409            FrictionLevel::L1 => self.theme.caution,
410            FrictionLevel::L2 | FrictionLevel::L3 | FrictionLevel::L4 => self.theme.alert,
411        };
412
413        let title = format!(" friction {level:?} — pause ", level = self.pause.level);
414        let block = Block::default()
415            .borders(Borders::ALL)
416            .title(Line::from(vec![Span::styled(
417                title,
418                Style::default()
419                    .fg(border_color)
420                    .add_modifier(Modifier::BOLD),
421            )]))
422            .border_style(Style::default().fg(border_color));
423
424        let inner = block.inner(rect);
425        block.render(rect, buf);
426
427        render_friction_body(inner, buf, self.theme, self.pause, self.now, border_color);
428    }
429}
430
431fn render_friction_body(
432    area: Rect,
433    buf: &mut Buffer,
434    theme: Theme,
435    fp: &FrictionPause,
436    now: Instant,
437    severity: ratatui::style::Color,
438) {
439    let mut y = area.top();
440    let width = area.width;
441
442    // Line 1: the command being gated, for unambiguous context.
443    // If the operator has three overlays across sessions they
444    // should never have to guess which /execute this is.
445    draw_line(
446        buf,
447        area,
448        &mut y,
449        width,
450        vec![
451            Span::styled("command  ", Style::default().fg(theme.metadata)),
452            Span::styled(
453                fp.command.name().to_string(),
454                Style::default()
455                    .fg(theme.primary)
456                    .add_modifier(Modifier::BOLD),
457            ),
458        ],
459    );
460
461    // Line 2: countdown in tenths so the timer doesn't look
462    // frozen between integer ticks. Severity-colored so at TILT
463    // the red number is part of the friction, not decoration.
464    draw_line(
465        buf,
466        area,
467        &mut y,
468        width,
469        vec![
470            Span::styled("pause    ", Style::default().fg(theme.metadata)),
471            Span::styled(
472                format_remaining(fp.remaining(now)),
473                Style::default().fg(severity).add_modifier(Modifier::BOLD),
474            ),
475            Span::styled(
476                format!(" / {}s", fp.pause.as_secs()),
477                Style::default().fg(theme.metadata),
478            ),
479        ],
480    );
481
482    // Blank separator before the typed-confirm surface (L2+) or
483    // the close hint (L1).
484    y = y.saturating_add(1);
485
486    if fp.confirm_word.is_some() {
487        render_confirm_input(area, buf, theme, fp, now, severity, &mut y);
488    }
489
490    render_close_hint(area, buf, theme);
491}
492
493fn render_confirm_input(
494    area: Rect,
495    buf: &mut Buffer,
496    theme: Theme,
497    fp: &FrictionPause,
498    now: Instant,
499    severity: ratatui::style::Color,
500    y: &mut u16,
501) {
502    let word = fp
503        .confirm_word
504        .as_deref()
505        .expect("caller gates on confirm_word presence");
506    let width = area.width;
507    let pause_elapsed = fp.pause_elapsed(now);
508    // Dim during the mandatory pause so the operator can *see*
509    // that typing is being rejected; switch to severity color
510    // once the field goes live.
511    let input_color = if pause_elapsed {
512        severity
513    } else {
514        theme.metadata
515    };
516    let prompt = if pause_elapsed {
517        format!("type '{word}' then Enter")
518    } else {
519        format!("type '{word}' after pause")
520    };
521    draw_line(
522        buf,
523        area,
524        y,
525        width,
526        vec![Span::styled(prompt, Style::default().fg(theme.metadata))],
527    );
528
529    // Input field with a trailing cursor glyph — solid once the
530    // pause is over, faint while paused so the pane does not
531    // look dead.
532    let cursor = if pause_elapsed { "▊" } else { "▌" };
533    draw_line(
534        buf,
535        area,
536        y,
537        width,
538        vec![
539            Span::styled("  > ", Style::default().fg(theme.metadata)),
540            Span::styled(
541                fp.confirm_input.clone(),
542                Style::default()
543                    .fg(input_color)
544                    .add_modifier(Modifier::BOLD),
545            ),
546            Span::styled(cursor, Style::default().fg(input_color)),
547        ],
548    );
549
550    // Match / mismatch hint — only after the pause, only when the
551    // operator started typing, so "mismatch" doesn't flash the
552    // moment the field opens.
553    if pause_elapsed && !fp.confirm_input.is_empty() {
554        let (text, color) = if fp.confirm_word_matches() {
555            ("match — command will run on the next tick", theme.primary)
556        } else if word.starts_with(fp.confirm_input.trim()) {
557            ("keep typing…", theme.metadata)
558        } else {
559            ("mismatch — backspace to correct", theme.alert)
560        };
561        draw_line(
562            buf,
563            area,
564            y,
565            width,
566            vec![Span::styled(text, Style::default().fg(color))],
567        );
568    }
569}
570
571fn render_close_hint(area: Rect, buf: &mut Buffer, theme: Theme) {
572    if area.height == 0 {
573        return;
574    }
575    let r = Rect {
576        x: area.x,
577        y: area.bottom().saturating_sub(1),
578        width: area.width,
579        height: 1,
580    };
581    Line::from(vec![Span::styled(
582        "Esc to cancel · Ctrl+C exits zero",
583        Style::default()
584            .fg(theme.metadata)
585            .add_modifier(Modifier::DIM),
586    )])
587    .render(r, buf);
588}
589
590/// Render the remaining pause as `Xs` at whole seconds and
591/// `X.Ys` otherwise, with a floor of `0.0s` so the field never
592/// blinks back to `Xs` after crossing zero.
593fn format_remaining(d: std::time::Duration) -> String {
594    if d.is_zero() {
595        return "0.0s".into();
596    }
597    let total = d.as_millis();
598    let seconds = total / 1000;
599    let tenths = (total % 1000) / 100;
600    if tenths == 0 {
601        format!("{seconds}.0s")
602    } else {
603        format!("{seconds}.{tenths}s")
604    }
605}
606
607/// Verdict overlay — centered modal that wraps [`VerdictBlock`]
608/// in a bordered, titled frame and paints a close hint.
609///
610/// The overlay owns none of the rendering logic for the card
611/// itself — that lives in [`VerdictBlock`] and is already unit-
612/// tested against the widget module. The overlay is a presentation
613/// wrapper: frame, clear, title, close hint. Any change to the
614/// verdict card's shape happens in one place.
615///
616/// # Dismissal
617///
618/// Same as the state overlay — any key closes the overlay; the
619/// input layer routes the dismissal, see `app::input`.
620#[derive(Debug)]
621pub struct VerdictOverlay<'a> {
622    pub evaluation: &'a Evaluation,
623    pub theme: Theme,
624}
625
626const VERDICT_PREFERRED_WIDTH: u16 = 72;
627const VERDICT_PREFERRED_HEIGHT: u16 = 14;
628
629impl Widget for VerdictOverlay<'_> {
630    fn render(self, area: Rect, buf: &mut Buffer) {
631        let rect = centered(area, VERDICT_PREFERRED_WIDTH, VERDICT_PREFERRED_HEIGHT);
632        Clear.render(rect, buf);
633
634        // Border color lifts `PASS`/`HOLD`/`REJECT` up to the title
635        // so the outcome is visible even in the peripheral-vision
636        // glance that a modal is designed for. Falls back to
637        // metadata for unknown / missing verdicts so the border
638        // never asserts an outcome the engine did not produce.
639        // The verdict string is derived from real fields (see
640        // `Evaluation::verdict`) rather than a wire field — an
641        // empty `layers` list means there is nothing to derive
642        // from, so fall through to the metadata color.
643        let border_color =
644            if self.evaluation.layers.is_empty() && self.evaluation.direction.is_none() {
645                self.theme.metadata
646            } else {
647                match crate::widgets::verdict::VerdictSeverity::parse(self.evaluation.verdict()) {
648                    crate::widgets::verdict::VerdictSeverity::Pass => self.theme.primary,
649                    crate::widgets::verdict::VerdictSeverity::Hold => self.theme.caution,
650                    crate::widgets::verdict::VerdictSeverity::Reject => self.theme.alert,
651                    crate::widgets::verdict::VerdictSeverity::Unknown => self.theme.metadata,
652                }
653            };
654
655        let title_text = match self.evaluation.coin.as_deref() {
656            Some(c) if !c.is_empty() => format!(" verdict · {c} "),
657            _ => " verdict ".to_string(),
658        };
659
660        let block = Block::default()
661            .borders(Borders::ALL)
662            .title(Line::from(vec![Span::styled(
663                title_text,
664                Style::default()
665                    .fg(border_color)
666                    .add_modifier(Modifier::BOLD),
667            )]))
668            .border_style(Style::default().fg(border_color));
669
670        let inner = block.inner(rect);
671        block.render(rect, buf);
672
673        // Leave the last inner row for the close hint so the
674        // hint is always visible no matter how many gates the
675        // verdict has; `VerdictBlock` renders top-aligned and
676        // truncates gracefully when it runs out of rows.
677        let card_rows = inner.height.saturating_sub(1);
678        let card_area = Rect {
679            x: inner.x,
680            y: inner.y,
681            width: inner.width,
682            height: card_rows,
683        };
684        VerdictBlock {
685            evaluation: self.evaluation,
686            theme: self.theme,
687        }
688        .render(card_area, buf);
689
690        put_close_hint(buf, inner, self.theme);
691    }
692}
693
694fn format_ms(ms: u64) -> String {
695    let s = ms / 1000;
696    if s == 0 && ms > 0 {
697        // Sub-second, but non-zero — print ms so tests that pass
698        // small numbers can still see the shape.
699        return format!("{ms}ms");
700    }
701    if s < 60 {
702        format!("{s}s")
703    } else if s < 3600 {
704        format!("{}m{:02}s", s / 60, s % 60)
705    } else {
706        format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
707    }
708}
709
710/// **M2 §4** risk overlay. Surfaces the engine's current `Risk`
711/// block alongside the operator-state snapshot's vector components
712/// so a TILT + guardrail-proximity situation is unambiguous: the
713/// operator sees exactly *how close* to the hard alert they are,
714/// and *why* the classifier flagged the operator as TILT.
715///
716/// Design constraints (same spirit as [`StateOverlay`]):
717/// - Context surface, not a gate — does not own a pending
718///   command, does not gate dispatch. Operator dismissal is any
719///   keypress; the auto-open hook will re-fire on the next tick
720///   if the guardrail signal is still live, subject to the 60 s
721///   cooldown.
722/// - Descriptive, not judgmental. Numbers and distances only.
723/// - L4 HardStop opens this overlay with a banner that says the
724///   engine is halted — the ceremony is *context*, not a bypass.
725///   Risk-reducing commands (`/kill`, `/flatten`, `/cancel`)
726///   still proceed through dispatch without the overlay
727///   blocking them (see `two_am_scenarios.rs`).
728#[derive(Debug)]
729pub struct RiskOverlay<'a> {
730    pub engine: &'a EngineState,
731    pub trigger: crate::app::state::RiskOverlayTrigger,
732    pub theme: Theme,
733    pub now: DateTime<Utc>,
734}
735
736/// Preferred minimum size for the Risk overlay. Narrower than the
737/// state overlay because the content is denser (fewer lines) and
738/// on an 80×24 terminal we want both a visible conversation
739/// margin and a centered card that cannot clip the "press any key"
740/// hint.
741const RISK_OVERLAY_WIDTH: u16 = 60;
742const RISK_OVERLAY_HEIGHT: u16 = 16;
743
744impl Widget for RiskOverlay<'_> {
745    fn render(self, area: Rect, buf: &mut Buffer) {
746        let rect = centered(area, RISK_OVERLAY_WIDTH, RISK_OVERLAY_HEIGHT);
747        Clear.render(rect, buf);
748
749        let (title_text, title_fg) = match self.trigger {
750            crate::app::state::RiskOverlayTrigger::Friction(FrictionLevel::L4) => {
751                (" engine halted — risk context ", self.theme.alert)
752            }
753            crate::app::state::RiskOverlayTrigger::Friction(FrictionLevel::L3) => {
754                (" approaching guardrail — risk context ", self.theme.caution)
755            }
756            crate::app::state::RiskOverlayTrigger::Friction(_) => {
757                (" risk context ", self.theme.primary)
758            }
759            crate::app::state::RiskOverlayTrigger::Proximity => {
760                (" drawdown near alert — risk context ", self.theme.caution)
761            }
762        };
763
764        let block = Block::default()
765            .borders(Borders::ALL)
766            .title(Line::from(vec![Span::styled(
767                title_text,
768                Style::default().fg(title_fg).add_modifier(Modifier::BOLD),
769            )]))
770            .border_style(Style::default().fg(self.theme.metadata));
771        let inner = block.inner(rect);
772        block.render(rect, buf);
773
774        render_risk_body(inner, buf, self.theme, self.engine, self.trigger, self.now);
775    }
776}
777
778#[allow(clippy::too_many_lines)]
779fn render_risk_body(
780    area: Rect,
781    buf: &mut Buffer,
782    theme: Theme,
783    engine: &EngineState,
784    trigger: crate::app::state::RiskOverlayTrigger,
785    now: DateTime<Utc>,
786) {
787    let mut y = area.top();
788    let width = area.width;
789
790    // ── Banner row — trigger reason ────────────────────────────
791    let banner = match trigger {
792        crate::app::state::RiskOverlayTrigger::Friction(FrictionLevel::L4) => (
793            "HARD STOP",
794            "engine halted; risk-reducing commands still go through",
795            theme.alert,
796        ),
797        crate::app::state::RiskOverlayTrigger::Friction(FrictionLevel::L3) => (
798            "L3 FRICTION",
799            "tilt + drawdown close to guardrail",
800            theme.caution,
801        ),
802        crate::app::state::RiskOverlayTrigger::Friction(_) => {
803            ("CAUTION", "friction escalated", theme.caution)
804        }
805        crate::app::state::RiskOverlayTrigger::Proximity => (
806            "PROXIMITY",
807            "drawdown within 0.5 pp of last alert",
808            theme.caution,
809        ),
810    };
811    draw_line(
812        buf,
813        area,
814        &mut y,
815        width,
816        vec![
817            Span::styled(
818                banner.0.to_string(),
819                Style::default().fg(banner.2).add_modifier(Modifier::BOLD),
820            ),
821            Span::styled("  ", Style::default()),
822            Span::styled(banner.1.to_string(), Style::default().fg(theme.metadata)),
823        ],
824    );
825    y = y.saturating_add(1);
826
827    // ── /risk line ────────────────────────────────────────────
828    draw_header(buf, area, &mut y, width, theme, "risk");
829    match engine.risk.as_ref() {
830        None => {
831            draw_line(
832                buf,
833                area,
834                &mut y,
835                width,
836                vec![Span::styled(
837                    "  engine has not reported risk yet",
838                    Style::default().fg(theme.metadata),
839                )],
840            );
841        }
842        Some(r) => {
843            let risk = &r.value;
844            let dd = risk.drawdown_pct.map_or("—".into(), |v| format!("{v:.2}%"));
845            let alert = risk
846                .last_drawdown_alert_pct
847                .map_or("—".into(), |v| format!("{v:.2}%"));
848            let distance = match (risk.drawdown_pct, risk.last_drawdown_alert_pct) {
849                (Some(d), Some(a)) => format!("{:+.2}pp", a - d),
850                _ => "—".into(),
851            };
852            draw_kv(
853                buf,
854                area,
855                &mut y,
856                width,
857                theme,
858                "drawdown",
859                &format!("{dd}   alert-at {alert}   Δ {distance}"),
860            );
861            let equity = risk
862                .account_value
863                .map_or("—".into(), |v| format!("${v:.0}"));
864            let peak = risk.peak_equity.map_or("—".into(), |v| format!("${v:.0}"));
865            draw_kv(
866                buf,
867                area,
868                &mut y,
869                width,
870                theme,
871                "equity",
872                &format!("{equity}   peak {peak}"),
873            );
874            let halt_state = if risk.is_halted() {
875                let reason = risk.halt_reason.as_deref().unwrap_or("halted");
876                format!("HALTED — {reason}")
877            } else {
878                "ok".to_string()
879            };
880            draw_kv(buf, area, &mut y, width, theme, "halt", &halt_state);
881        }
882    }
883    y = y.saturating_add(1);
884
885    // ── operator-state vector proximity components ────────────
886    draw_header(buf, area, &mut y, width, theme, "state");
887    match engine.operator_state.as_ref() {
888        None => {
889            draw_line(
890                buf,
891                area,
892                &mut y,
893                width,
894                vec![Span::styled(
895                    "  engine has not reported operator state yet",
896                    Style::default().fg(theme.metadata),
897                )],
898            );
899        }
900        Some(s) => {
901            let snap = &s.value;
902            let label_color = theme.resolve_hint(snap.label.color_hint());
903            let age = (now - s.as_of).num_seconds().max(0);
904            draw_line(
905                buf,
906                area,
907                &mut y,
908                width,
909                vec![
910                    Span::styled("  label    ", Style::default().fg(theme.metadata)),
911                    Span::styled(
912                        snap.label.short().to_string(),
913                        Style::default()
914                            .fg(label_color)
915                            .add_modifier(Modifier::BOLD),
916                    ),
917                    Span::styled("   friction ", Style::default().fg(theme.metadata)),
918                    Span::styled(
919                        format!("{:?}", snap.friction),
920                        Style::default().fg(theme.primary),
921                    ),
922                    Span::styled("   as-of ", Style::default().fg(theme.metadata)),
923                    Span::styled(format_age(age), Style::default().fg(theme.metadata)),
924                ],
925            );
926            let v = &snap.vector;
927            draw_kv(
928                buf,
929                area,
930                &mut y,
931                width,
932                theme,
933                "velocity",
934                &format!(
935                    "1h={} 4h={} 24h={}",
936                    v.velocity.last_1h, v.velocity.last_4h, v.velocity.last_24h
937                ),
938            );
939            draw_kv(
940                buf,
941                area,
942                &mut y,
943                width,
944                theme,
945                "re-entry",
946                &format!(
947                    "15m={} 30m={} 2h={}",
948                    v.re_entry.within_15m, v.re_entry.within_30m, v.re_entry.within_2h
949                ),
950            );
951            let sleep = v
952                .sleep_proxy
953                .hours_since_rest_ended
954                .map_or("—".into(), |h| format!("{h}h"));
955            draw_kv(
956                buf,
957                area,
958                &mut y,
959                width,
960                theme,
961                "sleep",
962                &format!("hours-since-rest={sleep}"),
963            );
964        }
965    }
966
967    put_close_hint(buf, area, theme);
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973    use chrono::TimeZone;
974    use ratatui::Terminal;
975    use ratatui::backend::TestBackend;
976    use zero_engine_client::{Source, Stat};
977    use zero_operator_state::{Label, StateVector};
978
979    fn render_overlay(engine: &EngineState, now: DateTime<Utc>) -> Vec<String> {
980        let backend = TestBackend::new(80, 24);
981        let mut term = Terminal::new(backend).expect("terminal");
982        term.draw(|f| {
983            let ov = StateOverlay {
984                engine,
985                theme: Theme::default(),
986                now,
987            };
988            f.render_widget(ov, f.area());
989        })
990        .expect("draw");
991        let buf = term.backend().buffer().clone();
992        (0..buf.area.height)
993            .map(|y| {
994                (0..buf.area.width)
995                    .map(|x| buf[(x, y)].symbol().to_string())
996                    .collect::<String>()
997            })
998            .collect()
999    }
1000
1001    fn snapshot_at(label: Label, as_of: DateTime<Utc>) -> Stat<OperatorSnapshot> {
1002        let snap = OperatorSnapshot::new(label, StateVector::default(), as_of, 1);
1003        Stat::new(snap, Source::Http).with_as_of(as_of)
1004    }
1005
1006    #[test]
1007    fn unseen_snapshot_shows_explanation_and_close_hint() {
1008        let now = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1009        let engine = EngineState::new();
1010        let lines = render_overlay(&engine, now);
1011        let joined = lines.join("\n");
1012        assert!(joined.contains("not reported"), "{joined}");
1013        assert!(joined.contains("/state"), "{joined}");
1014        assert!(joined.contains("press any key to close"), "{joined}");
1015    }
1016
1017    #[test]
1018    fn populated_snapshot_shows_label_friction_and_vector_keys() {
1019        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1020        let now = as_of + chrono::Duration::seconds(12);
1021        let mut engine = EngineState::new();
1022        engine.operator_state = Some(snapshot_at(Label::Elevated, as_of));
1023        let lines = render_overlay(&engine, now);
1024        let joined = lines.join("\n");
1025        assert!(joined.contains("ELEVATED"), "{joined}");
1026        assert!(joined.contains("friction"), "{joined}");
1027        assert!(joined.contains("L1"), "{joined}");
1028        assert!(joined.contains("state vector"), "{joined}");
1029        for key in [
1030            "velocity",
1031            "deviation",
1032            "session",
1033            "loss-reac",
1034            "re-entry",
1035            "sleep",
1036        ] {
1037            assert!(joined.contains(key), "missing {key} in: {joined}");
1038        }
1039        assert!(joined.contains("12s ago"), "{joined}");
1040    }
1041
1042    #[test]
1043    fn tiny_terminal_does_not_panic() {
1044        // 20×4 is smaller than the preferred size; the widget must
1045        // clamp and continue to paint.
1046        let as_of = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
1047        let mut engine = EngineState::new();
1048        engine.operator_state = Some(snapshot_at(Label::Tilt, as_of));
1049        let backend = TestBackend::new(20, 4);
1050        let mut term = Terminal::new(backend).expect("terminal");
1051        term.draw(|f| {
1052            let ov = StateOverlay {
1053                engine: &engine,
1054                theme: Theme::default(),
1055                now: as_of,
1056            };
1057            f.render_widget(ov, f.area());
1058        })
1059        .expect("tiny draw must not panic");
1060    }
1061
1062    #[test]
1063    fn format_age_boundaries() {
1064        assert_eq!(format_age(0), "0s ago");
1065        assert_eq!(format_age(59), "59s ago");
1066        assert_eq!(format_age(60), "1m0s ago");
1067        assert_eq!(format_age(3599), "59m59s ago");
1068        assert_eq!(format_age(3600), "1h ago");
1069    }
1070
1071    #[test]
1072    fn format_ms_boundaries() {
1073        assert_eq!(format_ms(0), "0s");
1074        assert_eq!(format_ms(500), "500ms");
1075        assert_eq!(format_ms(1_000), "1s");
1076        assert_eq!(format_ms(59_000), "59s");
1077        assert_eq!(format_ms(60_000), "1m00s");
1078        assert_eq!(format_ms(3_600_000), "1h00m");
1079    }
1080
1081    // ── FrictionPauseOverlay tests ────────────────────────────────
1082
1083    use std::time::Duration;
1084    use zero_commands::Command;
1085    use zero_operator_state::friction::FrictionLevel;
1086
1087    fn render_friction_at(fp: &FrictionPause, now: Instant) -> Vec<String> {
1088        let backend = TestBackend::new(80, 24);
1089        let mut term = Terminal::new(backend).expect("terminal");
1090        term.draw(|f| {
1091            let w = FrictionPauseOverlay {
1092                pause: fp,
1093                theme: Theme::default(),
1094                now,
1095            };
1096            f.render_widget(w, f.area());
1097        })
1098        .expect("draw");
1099        let buf = term.backend().buffer().clone();
1100        (0..buf.area.height)
1101            .map(|y| {
1102                (0..buf.area.width)
1103                    .map(|x| buf[(x, y)].symbol().to_string())
1104                    .collect::<String>()
1105            })
1106            .collect()
1107    }
1108
1109    #[test]
1110    fn l1_pause_shows_command_countdown_and_close_hint() {
1111        let started = Instant::now();
1112        let fp = FrictionPause {
1113            command: Command::Execute,
1114            level: FrictionLevel::L1,
1115            started_at: started,
1116            pause: Duration::from_secs(3),
1117            confirm_word: None,
1118            confirm_input: String::new(),
1119        };
1120        let lines = render_friction_at(&fp, started + Duration::from_millis(1_500));
1121        let joined = lines.join("\n");
1122        assert!(joined.contains("friction L1"), "{joined}");
1123        assert!(joined.contains("/execute"), "{joined}");
1124        assert!(joined.contains("1.5s"), "countdown tenths: {joined}");
1125        assert!(joined.contains("/ 3s"), "total pause shown: {joined}");
1126        assert!(joined.contains("Esc to cancel"), "{joined}");
1127        // L1 should never render a confirm-word prompt.
1128        assert!(
1129            !joined.contains("type '"),
1130            "L1 overlay must not show a confirm word"
1131        );
1132    }
1133
1134    #[test]
1135    fn l2_overlay_shows_confirm_word_and_dim_field_during_pause() {
1136        let started = Instant::now();
1137        let fp = FrictionPause {
1138            command: Command::Execute,
1139            level: FrictionLevel::L2,
1140            started_at: started,
1141            pause: Duration::from_secs(10),
1142            confirm_word: Some("execute".into()),
1143            confirm_input: String::new(),
1144        };
1145        let lines = render_friction_at(&fp, started + Duration::from_secs(3));
1146        let joined = lines.join("\n");
1147        assert!(joined.contains("friction L2"), "{joined}");
1148        assert!(joined.contains("type 'execute' after pause"), "{joined}");
1149        assert!(joined.contains("7.0s"), "remaining shown: {joined}");
1150    }
1151
1152    #[test]
1153    fn l2_overlay_shows_accept_prompt_once_pause_elapses() {
1154        let started = Instant::now()
1155            .checked_sub(Duration::from_secs(11))
1156            .expect("monotonic Instant supports 11s subtraction");
1157        let fp = FrictionPause {
1158            command: Command::Execute,
1159            level: FrictionLevel::L2,
1160            started_at: started,
1161            pause: Duration::from_secs(10),
1162            confirm_word: Some("execute".into()),
1163            confirm_input: "exec".into(),
1164        };
1165        let lines = render_friction_at(&fp, Instant::now());
1166        let joined = lines.join("\n");
1167        assert!(joined.contains("type 'execute' then Enter"), "{joined}");
1168        assert!(joined.contains("exec"), "confirm buffer shown: {joined}");
1169        assert!(
1170            joined.contains("keep typing"),
1171            "prefix-match hint: {joined}"
1172        );
1173    }
1174
1175    #[test]
1176    fn l2_overlay_surfaces_mismatch_when_wrong_word_typed() {
1177        let started = Instant::now()
1178            .checked_sub(Duration::from_secs(11))
1179            .expect("monotonic Instant supports 11s subtraction");
1180        let fp = FrictionPause {
1181            command: Command::Execute,
1182            level: FrictionLevel::L2,
1183            started_at: started,
1184            pause: Duration::from_secs(10),
1185            confirm_word: Some("execute".into()),
1186            confirm_input: "zzz".into(),
1187        };
1188        let lines = render_friction_at(&fp, Instant::now());
1189        let joined = lines.join("\n");
1190        assert!(joined.contains("mismatch"), "{joined}");
1191    }
1192
1193    #[test]
1194    fn l2_overlay_reports_match_when_word_complete() {
1195        let started = Instant::now()
1196            .checked_sub(Duration::from_secs(11))
1197            .expect("monotonic Instant supports 11s subtraction");
1198        let fp = FrictionPause {
1199            command: Command::Execute,
1200            level: FrictionLevel::L2,
1201            started_at: started,
1202            pause: Duration::from_secs(10),
1203            confirm_word: Some("execute".into()),
1204            confirm_input: "execute".into(),
1205        };
1206        let lines = render_friction_at(&fp, Instant::now());
1207        let joined = lines.join("\n");
1208        assert!(joined.contains("match"), "{joined}");
1209    }
1210
1211    #[test]
1212    fn format_remaining_boundaries() {
1213        assert_eq!(format_remaining(Duration::ZERO), "0.0s");
1214        assert_eq!(format_remaining(Duration::from_millis(100)), "0.1s");
1215        assert_eq!(format_remaining(Duration::from_millis(1_000)), "1.0s");
1216        assert_eq!(format_remaining(Duration::from_millis(2_900)), "2.9s");
1217        assert_eq!(format_remaining(Duration::from_secs(10)), "10.0s");
1218    }
1219
1220    // ── VerdictOverlay tests ──────────────────────────────────────
1221
1222    use zero_engine_client::Evaluation;
1223    use zero_engine_client::models::EvaluationLayer;
1224
1225    fn render_verdict(eval: &Evaluation, width: u16, height: u16) -> Vec<String> {
1226        let backend = TestBackend::new(width, height);
1227        let mut term = Terminal::new(backend).expect("terminal");
1228        term.draw(|f| {
1229            let w = VerdictOverlay {
1230                evaluation: eval,
1231                theme: Theme::default(),
1232            };
1233            f.render_widget(w, f.area());
1234        })
1235        .expect("draw");
1236        let buf = term.backend().buffer().clone();
1237        (0..buf.area.height)
1238            .map(|y| {
1239                (0..buf.area.width)
1240                    .map(|x| buf[(x, y)].symbol().to_string())
1241                    .collect::<String>()
1242            })
1243            .collect()
1244    }
1245
1246    fn pass_eval() -> Evaluation {
1247        Evaluation {
1248            coin: Some("BTC".into()),
1249            direction: Some("LONG".into()),
1250            conviction: Some(0.72),
1251            regime: Some("trending".into()),
1252            consensus: Some(8),
1253            layers: vec![
1254                EvaluationLayer {
1255                    layer: "layer_0".into(),
1256                    passed: true,
1257                    value: serde_json::Value::Null,
1258                    detail: String::new(),
1259                },
1260                EvaluationLayer {
1261                    layer: "layer_1".into(),
1262                    passed: true,
1263                    value: serde_json::Value::Null,
1264                    detail: String::new(),
1265                },
1266                EvaluationLayer {
1267                    layer: "layer_2".into(),
1268                    passed: true,
1269                    value: serde_json::Value::Null,
1270                    detail: String::new(),
1271                },
1272            ],
1273            ..Default::default()
1274        }
1275    }
1276
1277    #[test]
1278    fn verdict_overlay_title_carries_coin_and_card_renders_chip() {
1279        let lines = render_verdict(&pass_eval(), 80, 16);
1280        let joined = lines.join("\n");
1281        assert!(
1282            joined.contains("verdict · BTC"),
1283            "title missing coin: {joined}"
1284        );
1285        assert!(joined.contains("PASS"), "chip missing: {joined}");
1286        assert!(joined.contains("conf 72%"), "confidence missing: {joined}");
1287        assert!(
1288            joined.contains("press any key to close"),
1289            "close hint missing: {joined}"
1290        );
1291    }
1292
1293    #[test]
1294    fn verdict_overlay_empty_eval_shows_honest_card() {
1295        let eval = Evaluation {
1296            coin: Some("BTC".into()),
1297            ..Default::default()
1298        };
1299        let lines = render_verdict(&eval, 80, 10);
1300        let joined = lines.join("\n");
1301        // The card's own empty-state line survives into the overlay.
1302        assert!(
1303            joined.contains("no verdict"),
1304            "empty card leaked through overlay: {joined}"
1305        );
1306        // Must NOT leak a fake chip through the overlay frame.
1307        for needle in [" PASS ", " HOLD ", " REJECT "] {
1308            assert!(!joined.contains(needle), "fake {needle} leaked: {joined}");
1309        }
1310    }
1311
1312    #[test]
1313    fn verdict_overlay_missing_coin_keeps_plain_title() {
1314        let eval = Evaluation {
1315            direction: Some("NONE".into()),
1316            layers: vec![EvaluationLayer {
1317                layer: "layer_0".into(),
1318                passed: true,
1319                value: serde_json::Value::Null,
1320                detail: String::new(),
1321            }],
1322            ..Default::default()
1323        };
1324        let lines = render_verdict(&eval, 80, 10);
1325        let joined = lines.join("\n");
1326        assert!(joined.contains("verdict"), "title missing: {joined}");
1327        // No `·` divider when there is no coin name to follow it.
1328        assert!(
1329            !joined.contains("· "),
1330            "title must not have dangling separator: {joined}"
1331        );
1332    }
1333
1334    #[test]
1335    fn verdict_overlay_tiny_terminal_does_not_panic() {
1336        let eval = pass_eval();
1337        let backend = TestBackend::new(20, 4);
1338        let mut term = Terminal::new(backend).expect("terminal");
1339        term.draw(|f| {
1340            let w = VerdictOverlay {
1341                evaluation: &eval,
1342                theme: Theme::default(),
1343            };
1344            f.render_widget(w, f.area());
1345        })
1346        .expect("tiny draw must not panic");
1347    }
1348}