Skip to main content

zero_tui/widgets/
pane.rs

1//! Stub panes for Positions, Decisions, Heat.
2//!
3//! M1 ships the shell with minimal per-mode content so the operator
4//! can switch modes, see live engine state for the ones we have
5//! (positions, risk), and know the others are on the roadmap.
6//! Real dashboards land in subsequent milestones.
7
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span};
12use ratatui::widgets::Widget;
13use serde_json::Value;
14use zero_engine_client::{EngineState, LiveCockpit};
15
16use crate::theme::Theme;
17use crate::widgets::position_row::PositionRow;
18
19#[derive(Debug)]
20pub struct PositionsPane<'a> {
21    pub engine: &'a EngineState,
22    pub theme: Theme,
23}
24
25impl Widget for PositionsPane<'_> {
26    fn render(self, area: Rect, buf: &mut Buffer) {
27        clear(area, buf);
28        let header = Line::from(vec![Span::styled(
29            " positions ",
30            Style::default()
31                .fg(self.theme.primary)
32                .add_modifier(Modifier::BOLD),
33        )]);
34        header.render(subrect(area, 0, 1), buf);
35
36        let Some(stat) = &self.engine.positions else {
37            Line::from(vec![Span::styled(
38                " (no positions seen — waiting for engine)",
39                Style::default().fg(self.theme.metadata),
40            )])
41            .render(subrect(area, 2, 1), buf);
42            return;
43        };
44
45        let pos = &stat.value;
46        if pos.items.is_empty() {
47            Line::from(vec![Span::styled(
48                " (flat — no open positions)",
49                Style::default().fg(self.theme.metadata),
50            )])
51            .render(subrect(area, 2, 1), buf);
52            return;
53        }
54
55        // No column header — every cell in `PositionRow` is
56        // self-labeling (`size=…`, `entry=…`, …), so a header
57        // would be visually redundant.
58        for (i, p) in pos.items.iter().enumerate() {
59            let y = u16::try_from(2 + i).unwrap_or(u16::MAX);
60            let r = subrect(area, y, 1);
61            if r.y >= area.bottom() {
62                break;
63            }
64            PositionRow {
65                position: p,
66                theme: self.theme,
67            }
68            .render(r, buf);
69        }
70    }
71}
72
73#[derive(Debug)]
74pub struct DecisionsPane {
75    pub theme: Theme,
76}
77
78impl Widget for DecisionsPane {
79    fn render(self, area: Rect, buf: &mut Buffer) {
80        clear(area, buf);
81        Line::from(vec![Span::styled(
82            " decisions ",
83            Style::default()
84                .fg(self.theme.primary)
85                .add_modifier(Modifier::BOLD),
86        )])
87        .render(subrect(area, 0, 1), buf);
88
89        Line::from(vec![Span::styled(
90            " (decisions stream lands with `/decisions` command dispatch)",
91            Style::default().fg(self.theme.metadata),
92        )])
93        .render(subrect(area, 2, 1), buf);
94    }
95}
96
97#[derive(Debug)]
98pub struct HeatPane<'a> {
99    pub engine: &'a EngineState,
100    pub theme: Theme,
101}
102
103impl Widget for HeatPane<'_> {
104    fn render(self, area: Rect, buf: &mut Buffer) {
105        clear(area, buf);
106        Line::from(vec![Span::styled(
107            " heat ",
108            Style::default()
109                .fg(self.theme.primary)
110                .add_modifier(Modifier::BOLD),
111        )])
112        .render(subrect(area, 0, 1), buf);
113
114        let body: Vec<Span<'_>> = if let Some(risk) = &self.engine.risk {
115            let r = &risk.value;
116            let halted_style = if r.is_halted() {
117                Style::default()
118                    .fg(self.theme.alert)
119                    .add_modifier(Modifier::BOLD)
120            } else {
121                Style::default().fg(self.theme.primary)
122            };
123            let halted = if r.is_halted() { "HALTED" } else { "OK" };
124            vec![
125                Span::styled(" risk: ", Style::default().fg(self.theme.metadata)),
126                Span::styled(halted, halted_style),
127                Span::styled(
128                    {
129                        let dd = r.drawdown_pct.map_or("—".into(), |v| format!("{v:.1}%"));
130                        let loss = r
131                            .daily_loss_pct()
132                            .map(|v| format!("{v:.1}%"))
133                            .or_else(|| r.daily_loss_usd.map(|v| format!("${v:.2}")))
134                            .unwrap_or_else(|| "—".into());
135                        let open = r.open_count.map_or("—".into(), |n| n.to_string());
136                        format!("  dd:{dd}  daily-loss:{loss}  open:{open}")
137                    },
138                    Style::default().fg(self.theme.metadata),
139                ),
140            ]
141        } else {
142            vec![Span::styled(
143                " (no risk snapshot yet — waiting for engine)",
144                Style::default().fg(self.theme.metadata),
145            )]
146        };
147
148        Line::from(body).render(subrect(area, 2, 1), buf);
149    }
150}
151
152#[derive(Debug)]
153pub struct CockpitPane<'a> {
154    pub engine: &'a EngineState,
155    pub theme: Theme,
156}
157
158impl Widget for CockpitPane<'_> {
159    fn render(self, area: Rect, buf: &mut Buffer) {
160        clear(area, buf);
161        Line::from(vec![Span::styled(
162            " live cockpit ",
163            Style::default()
164                .fg(self.theme.primary)
165                .add_modifier(Modifier::BOLD),
166        )])
167        .render(subrect(area, 0, 1), buf);
168
169        let Some(stat) = &self.engine.live_cockpit else {
170            Line::from(vec![Span::styled(
171                " (no cockpit packet yet — waiting for /live/cockpit poll)",
172                Style::default().fg(self.theme.metadata),
173            )])
174            .render(subrect(area, 2, 1), buf);
175            return;
176        };
177
178        let c = &stat.value;
179        render_cockpit_header(area, buf, self.theme, c);
180        render_cockpit_summary(area, buf, self.theme, c);
181        let y = render_cockpit_findings(area, buf, self.theme, c, 13);
182        line(
183            area,
184            buf,
185            y.saturating_add(1),
186            self.theme,
187            " actions",
188            "reduce=/pause-entries /kill /flatten-all  resume=/resume-entries",
189        );
190    }
191}
192
193fn render_cockpit_header(area: Rect, buf: &mut Buffer, theme: Theme, c: &LiveCockpit) {
194    let header_style = if c.ready && c.risk_increasing_allowed {
195        Style::default().fg(theme.primary)
196    } else {
197        Style::default()
198            .fg(theme.alert)
199            .add_modifier(Modifier::BOLD)
200    };
201    Line::from(vec![
202        Span::styled(" live_mode=", Style::default().fg(theme.metadata)),
203        Span::styled(c.live_mode.as_str(), header_style),
204        Span::styled(
205            format!(
206                "  ready={}  risk_allowed={}  controls_ready={}",
207                c.ready, c.risk_increasing_allowed, c.controls_ready
208            ),
209            Style::default().fg(theme.metadata),
210        ),
211    ])
212    .render(subrect(area, 2, 1), buf);
213
214    line(area, buf, 3, theme, " next", c.next_action.as_str());
215    line(
216        area,
217        buf,
218        4,
219        theme,
220        " operator",
221        &format!(
222            "handle={} id={} role={} scope={}",
223            c.operator_context.handle,
224            c.operator_context.operator_id,
225            c.operator_context.role,
226            c.operator_context.scope
227        ),
228    );
229}
230
231fn render_cockpit_summary(area: Rect, buf: &mut Buffer, theme: Theme, c: &LiveCockpit) {
232    let preflight_total = json_u64(&c.preflight.summary, "total");
233    let preflight_passed = json_u64(&c.preflight.summary, "passed");
234    let preflight_failed = json_u64(&c.preflight.summary, "failed");
235    let immune_open = json_u64(&c.immune.summary, "open");
236    let immune_blocking = json_u64(&c.immune.summary, "risk_blocking");
237    let cert_total = json_u64(&c.certification.summary, "total");
238    let cert_passed = json_u64(&c.certification.summary, "passed");
239    let timeout = c
240        .heartbeat
241        .timeout_s
242        .map_or_else(|| "n/a".to_string(), |s| s.to_string());
243
244    line(
245        area,
246        buf,
247        6,
248        theme,
249        " preflight",
250        &format!("passed={preflight_passed}/{preflight_total} failed={preflight_failed}"),
251    );
252    line(
253        area,
254        buf,
255        7,
256        theme,
257        " immune",
258        &format!("open={immune_open} risk_blocking={immune_blocking}"),
259    );
260    line(
261        area,
262        buf,
263        8,
264        theme,
265        " reconcile",
266        &format!(
267            "status={} risk_allowed={} drifts={} - {}",
268            c.reconciliation.status,
269            c.reconciliation.risk_increasing_allowed,
270            c.reconciliation.drifts,
271            c.reconciliation.reason
272        ),
273    );
274    line(
275        area,
276        buf,
277        9,
278        theme,
279        " certification",
280        &format!(
281            "passed={} live_start_certified={} drills={cert_passed}/{cert_total}",
282            c.certification.passed, c.certification.live_start_certified
283        ),
284    );
285    line(
286        area,
287        buf,
288        10,
289        theme,
290        " heartbeat",
291        &format!(
292            "configured={} expired={} timeout_s={timeout}",
293            c.heartbeat.configured, c.heartbeat.expired
294        ),
295    );
296    line(
297        area,
298        buf,
299        11,
300        theme,
301        " receipts",
302        &format!(
303            "total={} accepted={} refused={} exchange_error={}",
304            c.live_records.total,
305            c.live_records.accepted,
306            c.live_records.refused,
307            c.live_records.exchange_error
308        ),
309    );
310}
311
312fn render_cockpit_findings(
313    area: Rect,
314    buf: &mut Buffer,
315    theme: Theme,
316    c: &LiveCockpit,
317    mut y: u16,
318) -> u16 {
319    for check in c.preflight.failed_checks.iter().take(4) {
320        line(
321            area,
322            buf,
323            y,
324            theme,
325            " preflight",
326            &format!("{} {} - {}", check.name, check.status, check.note),
327        );
328        y += 1;
329    }
330    for breaker in c.immune.open_breakers.iter().take(4) {
331        line(
332            area,
333            buf,
334            y,
335            theme,
336            " breaker",
337            &format!("{} {} - {}", breaker.name, breaker.status, breaker.reason),
338        );
339        y += 1;
340    }
341    y
342}
343
344fn line(area: Rect, buf: &mut Buffer, y: u16, theme: Theme, label: &str, value: &str) {
345    if y >= area.height {
346        return;
347    }
348    Line::from(vec![
349        Span::styled(label.to_string(), Style::default().fg(theme.metadata)),
350        Span::styled(": ", Style::default().fg(theme.metadata)),
351        Span::styled(value.to_string(), Style::default().fg(theme.primary)),
352    ])
353    .render(subrect(area, y, 1), buf);
354}
355
356fn json_u64(map: &std::collections::BTreeMap<String, Value>, key: &str) -> u64 {
357    map.get(key).and_then(Value::as_u64).unwrap_or(0)
358}
359
360fn clear(area: Rect, buf: &mut Buffer) {
361    for y in area.top()..area.bottom() {
362        for x in area.left()..area.right() {
363            buf[(x, y)].set_char(' ');
364        }
365    }
366}
367
368fn subrect(area: Rect, y_offset: u16, height: u16) -> Rect {
369    Rect {
370        x: area.x,
371        y: area
372            .y
373            .saturating_add(y_offset)
374            .min(area.bottom().saturating_sub(1)),
375        width: area.width,
376        height: height.min(area.height.saturating_sub(y_offset)),
377    }
378}