1use 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
94const OPERATOR_STATE_STALE_AFTER: Duration = Duration::seconds(30);
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum Tier {
104 Full,
106 Compact,
108 Minimal,
110}
111
112#[derive(Debug)]
113pub struct StatusBar<'a> {
114 pub mode: Mode,
115 pub engine: &'a EngineState,
116 pub theme: Theme,
117 pub now: DateTime<Utc>,
121 pub rate_budget: Option<BudgetSnapshot>,
128}
129
130impl Widget for StatusBar<'_> {
131 fn render(self, area: Rect, buf: &mut Buffer) {
132 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(make_bar(30), Tier::Minimal);
684 }
685
686 fn bar_with_budget(snap: BudgetSnapshot) -> StatusBar<'static> {
695 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 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 assert_eq!(mk(60), theme.primary);
778 assert_eq!(mk(15), theme.primary);
780 assert_eq!(mk(14), theme.caution);
782 assert_eq!(mk(7), theme.caution);
784 assert_eq!(mk(5), theme.alert);
786 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 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 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}