1use 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#[derive(Debug)]
37pub struct StateOverlay<'a> {
38 pub engine: &'a EngineState,
39 pub theme: Theme,
40 pub now: DateTime<Utc>,
43}
44
45const 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.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 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 draw_header(buf, area, &mut y, width, theme, "state vector");
169
170 let v = &snap.vector;
171
172 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 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 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 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 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 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 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
355fn 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#[derive(Debug)]
390pub struct FrictionPauseOverlay<'a> {
391 pub pause: &'a FrictionPause,
392 pub theme: Theme,
393 pub now: Instant,
394}
395
396const 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 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 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 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 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 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 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 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
590fn 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#[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 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 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 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#[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
736const 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 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 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 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 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 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 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 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 assert!(
1303 joined.contains("no verdict"),
1304 "empty card leaked through overlay: {joined}"
1305 );
1306 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 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}