1use 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 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}