Skip to main content

schwab_cli/ui/
tui_render.rs

1//! Ratatui-native render helpers (no ANSI / unicode box panels).
2
3use chrono::Utc;
4use ratatui::style::{Color, Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Gauge;
7
8use super::agent_health::{format_tick_error, SharedAgentHealth};
9use super::context::DashboardContext;
10use super::market_status::market_label;
11use super::watch::WatchAgentMode;
12use super::{ago_secs, format_duration_secs};
13use crate::auth_reminder::AuthReminderLevel;
14
15fn format_position_summary(state: &crate::agent::state::AgentState) -> String {
16    let spreads = state.open_positions.len();
17    if spreads == 0 {
18        return "flat".into();
19    }
20    let contracts = state.total_contracts();
21    if contracts > spreads as u32 {
22        format!(
23            "{} spread{} · {} ct",
24            spreads,
25            if spreads == 1 { "" } else { "s" },
26            contracts
27        )
28    } else {
29        format!("{} spread{}", spreads, if spreads == 1 { "" } else { "s" })
30    }
31}
32
33fn truncate_err(msg: &str, max: usize) -> String {
34    if msg.len() <= max {
35        msg.to_string()
36    } else {
37        format!("{}…", &msg[..max.saturating_sub(1)])
38    }
39}
40
41fn embedded_agent_label(
42    ctx: &DashboardContext,
43    health: Option<&SharedAgentHealth>,
44) -> (String, bool) {
45    let Some(h) = health.and_then(|h| h.lock().ok()) else {
46        return ("running (in-process)".into(), true);
47    };
48    if h.auth_required {
49        return ("auth required — schwab auth login".into(), false);
50    }
51    if !h.loop_running {
52        let err = h
53            .last_error
54            .as_deref()
55            .map(|e| truncate_err(&format_tick_error(e), 48))
56            .unwrap_or_else(|| "exited".into());
57        return (format!("stopped ({err})"), false);
58    }
59    if ctx.tick_is_stale() && h.ticks_completed == 0 {
60        let starting = h.started_at.elapsed().as_secs();
61        let err = h
62            .last_error
63            .as_deref()
64            .map(|e| truncate_err(&format_tick_error(e), 36))
65            .unwrap_or_else(|| {
66                if starting > 120 {
67                    format!("no tick in {}s", starting)
68                } else {
69                    "starting…".into()
70                }
71            });
72        return (format!("running ({err})"), true);
73    }
74    if ctx.tick_is_stale() {
75        return ("running (tick stale)".into(), true);
76    }
77    (format!("running ({} ticks)", h.ticks_completed), true)
78}
79
80pub fn header_line(
81    ctx: &DashboardContext,
82    agent_mode: WatchAgentMode,
83    agent_health: Option<&SharedAgentHealth>,
84) -> Line<'static> {
85    let daemon = match agent_mode {
86        WatchAgentMode::Embedded => {
87            let (label, ok) = embedded_agent_label(ctx, agent_health);
88            let style = if ok && !ctx.tick_is_stale() {
89                Style::default().fg(Color::Green)
90            } else if ok {
91                Style::default().fg(Color::Yellow)
92            } else {
93                Style::default().fg(Color::Red)
94            };
95            Span::styled(format!("● {label} "), style)
96        }
97        WatchAgentMode::External => Span::styled(
98            format!("● running pid {} ", ctx.daemon.pid.unwrap_or(0)),
99            Style::default().fg(Color::Green),
100        ),
101        WatchAgentMode::MonitorOnly => {
102            if ctx.daemon.running {
103                Span::styled(
104                    format!("● running pid {} ", ctx.daemon.pid.unwrap_or(0)),
105                    Style::default().fg(Color::Green),
106                )
107            } else {
108                Span::styled("○ stopped ", Style::default().fg(Color::Red))
109            }
110        }
111    };
112
113    let session = ctx.effective_session();
114    let session_style = match session {
115        "regular" => Style::default().fg(Color::Green),
116        "overnight" => Style::default().fg(Color::Magenta),
117        _ => Style::default().fg(Color::DarkGray),
118    };
119
120    let (mkt_label, mkt_open) = market_label(ctx.market_status, Some(session));
121    let mkt_style = if mkt_open {
122        Style::default()
123            .fg(Color::Green)
124            .add_modifier(Modifier::BOLD)
125    } else {
126        Style::default().fg(Color::Red)
127    };
128
129    Line::from(vec![
130        Span::styled(
131            format!("✦ {} ", ctx.rules.agent_id),
132            Style::default()
133                .fg(Color::Cyan)
134                .add_modifier(Modifier::BOLD),
135        ),
136        daemon,
137        Span::raw("· "),
138        Span::styled(mkt_label.to_string(), mkt_style),
139        Span::raw(" · "),
140        Span::styled(session.to_string(), session_style),
141        Span::raw(format!(
142            " · {} · tick {}s",
143            format_position_summary(&ctx.state),
144            ctx.expected_tick_interval_secs()
145        )),
146    ])
147}
148
149pub fn agent_status_lines(
150    ctx: &DashboardContext,
151    agent_mode: WatchAgentMode,
152    agent_health: Option<&SharedAgentHealth>,
153) -> Vec<Line<'static>> {
154    let mut lines = Vec::new();
155    let daemon_val = match agent_mode {
156        WatchAgentMode::Embedded => embedded_agent_label(ctx, agent_health).0,
157        WatchAgentMode::External => format!("running (pid {})", ctx.daemon.pid.unwrap_or(0)),
158        WatchAgentMode::MonitorOnly => {
159            if ctx.daemon.running {
160                format!("running (pid {})", ctx.daemon.pid.unwrap_or(0))
161            } else {
162                "stopped".into()
163            }
164        }
165    };
166    lines.push(kv_line("daemon", daemon_val, 12));
167
168    let session = ctx.effective_session();
169    let (mkt_label, _) = market_label(ctx.market_status, Some(session));
170    lines.push(kv_line("EQO regular", mkt_label.to_string(), 12));
171    lines.push(kv_line(
172        "session",
173        format!(
174            "{} (now){}",
175            session,
176            ctx.state
177                .last_session
178                .as_deref()
179                .filter(|s| *s != session)
180                .map(|s| format!(" · saved: {s}"))
181                .unwrap_or_default()
182        ),
183        12,
184    ));
185
186    if let Some(reminder) = ctx.auth_reminder.as_ref() {
187        if reminder.level != AuthReminderLevel::None {
188            let style = match reminder.level {
189                AuthReminderLevel::Soon => Style::default().fg(Color::Yellow),
190                AuthReminderLevel::Urgent | AuthReminderLevel::Expired => {
191                    Style::default().fg(Color::Red)
192                }
193                AuthReminderLevel::None => Style::default(),
194            };
195            lines.push(Line::from(vec![
196                Span::styled("  Schwab auth ", Style::default().fg(Color::DarkGray)),
197                Span::styled(reminder.message.clone(), style),
198            ]));
199            lines.push(Line::from(vec![
200                Span::styled("               ", Style::default().fg(Color::DarkGray)),
201                Span::styled(reminder.detail_line(), Style::default().fg(Color::DarkGray)),
202            ]));
203        }
204    }
205
206    if let Some(age) = ctx.last_tick_age_secs() {
207        let tick_label = ago_secs(age);
208        let style = if ctx.tick_is_stale() {
209            Style::default().fg(Color::Red)
210        } else {
211            Style::default()
212        };
213        lines.push(Line::from(vec![
214            Span::styled("  last tick  ", Style::default().fg(Color::DarkGray)),
215            Span::styled(tick_label, style),
216        ]));
217    } else {
218        lines.push(kv_line("last tick", "never".into(), 12));
219    }
220
221    lines.push(kv_line(
222        "next tick",
223        format!(
224            "~{}",
225            format_duration_secs(ctx.expected_tick_interval_secs())
226        ),
227        12,
228    ));
229    lines.push(kv_line(
230        "positions",
231        format!(
232            "{} · {} pending",
233            format_position_summary(&ctx.state),
234            ctx.state.pending_count()
235        ),
236        12,
237    ));
238    lines.push(kv_line(
239        "trades today",
240        format!(
241            "{}/{}",
242            ctx.state.trades_today, ctx.rules.risk.max_trades_per_day
243        ),
244        12,
245    ));
246    if let Some(h) = agent_health.and_then(|h| h.lock().ok()) {
247        if let Some(err) = h.last_error.as_ref() {
248            lines.push(Line::from(vec![
249                Span::styled("  last error ", Style::default().fg(Color::DarkGray)),
250                Span::styled(
251                    format_tick_error(err),
252                    Style::default().fg(Color::Red),
253                ),
254            ]));
255        }
256    }
257    if ctx.rules.llm.enabled {
258        let phase = ctx
259            .state
260            .last_llm_summary
261            .as_ref()
262            .and_then(|v| v.get("phase"))
263            .and_then(|v| v.as_str())
264            .unwrap_or("—");
265        lines.push(kv_line(
266            "LLM",
267            format!("{phase} · {} reviews", ctx.state.llm_review_count),
268            12,
269        ));
270    }
271    lines
272}
273
274pub fn rules_summary_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
275    let rules = &ctx.rules;
276    let mut lines = vec![
277        kv_line(
278            "tick",
279            format!(
280                "{} · monitor ~{}m",
281                format_duration_secs(rules.schedule.tick_interval_seconds),
282                ctx.monitor_interval_minutes()
283            ),
284            10,
285        ),
286        kv_line("watchlist", rules.watchlist.join(", "), 10),
287    ];
288
289    if rules.strategies.vertical.enabled {
290        let v = &rules.entry_rules.vertical;
291        lines.push(kv_line(
292            "vertical",
293            format!("{} {}–{} DTE", v.r#type, v.dte_min, v.dte_max),
294            10,
295        ));
296    }
297    if rules.strategies.iron_condor.enabled {
298        let ic = &rules.entry_rules.iron_condor;
299        lines.push(kv_line(
300            "iron condor",
301            format!("{}–{} DTE", ic.dte_min, ic.dte_max),
302            10,
303        ));
304    }
305
306    if rules.llm.enabled {
307        lines.push(kv_line(
308            "LLM",
309            format!(
310                "{} / {}",
311                short_model(rules.llm.effective_selection_model()),
312                short_model(rules.llm.effective_monitor_model())
313            ),
314            10,
315        ));
316    }
317
318    if rules.schedule.overnight.enabled {
319        lines.push(kv_line(
320            "overnight",
321            format!(
322                "every {} · digest {}",
323                format_duration_secs(rules.schedule.overnight.tick_interval_seconds),
324                if rules.schedule.overnight.web_digest {
325                    "on"
326                } else {
327                    "off"
328                }
329            ),
330            10,
331        ));
332    }
333
334    lines
335}
336
337pub fn risk_gauge(ctx: &DashboardContext) -> Gauge<'static> {
338    let used = ctx.portfolio_risk_usd();
339    let max = ctx.rules.risk.max_portfolio_risk_usd.max(1.0);
340    let ratio = (used / max).clamp(0.0, 1.0);
341    Gauge::default()
342        .gauge_style(
343            Style::default()
344                .fg(if ratio > 0.8 {
345                    Color::Red
346                } else if ratio > 0.5 {
347                    Color::Yellow
348                } else {
349                    Color::Green
350                })
351                .bg(Color::DarkGray),
352        )
353        .ratio(ratio)
354        .label(format!("risk ${used:.0} / ${max:.0}"))
355}
356
357pub fn activity_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
358    ctx.state
359        .last_actions
360        .iter()
361        .rev()
362        .take(12)
363        .map(|act| {
364            let time = act.at.format("%H:%M").to_string();
365            let detail = format_action_detail(&act.action, &act.detail);
366            Line::from(vec![
367                Span::styled(format!("{time} "), Style::default().fg(Color::DarkGray)),
368                Span::styled(
369                    format!("{:<14} ", act.action),
370                    Style::default().add_modifier(Modifier::BOLD),
371                ),
372                Span::raw(detail),
373            ])
374        })
375        .collect()
376}
377
378pub fn position_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
379    if ctx.state.open_positions.is_empty() {
380        return vec![Line::from(Span::styled(
381            "(flat — no open positions)",
382            Style::default().fg(Color::DarkGray),
383        ))];
384    }
385    ctx.state
386        .open_positions
387        .values()
388        .map(|p| {
389            let contracts = if p.contracts > 1 {
390                format!(" ×{}", p.contracts)
391            } else {
392                String::new()
393            };
394            let credit = p
395                .entry_credit
396                .map(|c| format!(" · cr ${c:.2}"))
397                .unwrap_or_default();
398            let opened = ago_secs((Utc::now() - p.opened_at).num_seconds());
399            Line::from(vec![
400                Span::styled(
401                    format!("{}{} ", p.underlying, contracts),
402                    Style::default()
403                        .fg(Color::Cyan)
404                        .add_modifier(Modifier::BOLD),
405                ),
406                Span::raw(format!(
407                    "{} · exp {} · max loss ${:.0}{credit} · ",
408                    p.strategy, p.expiry, p.max_loss_usd
409                )),
410                Span::styled("holding", Style::default().fg(Color::Green)),
411                Span::styled(format!(" · {opened}"), Style::default().fg(Color::DarkGray)),
412            ])
413        })
414        .collect()
415}
416
417/// Compact latest LLM review for the overview panel.
418pub fn latest_llm_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
419    if !ctx.rules.llm.enabled {
420        return vec![Line::from(Span::styled(
421            "LLM disabled in rules",
422            Style::default().fg(Color::DarkGray),
423        ))];
424    }
425
426    let review = latest_llm_review(ctx);
427    let Some(review) = review else {
428        return vec![Line::from(Span::styled(
429            "No LLM reviews yet",
430            Style::default().fg(Color::DarkGray),
431        ))];
432    };
433
434    let mut lines = format_llm_review_lines(review, None);
435    if lines.len() > 8 {
436        lines.truncate(8);
437        lines.push(Line::from(Span::styled(
438            "  … see LLM tab (5) for full history",
439            Style::default().fg(Color::DarkGray),
440        )));
441    }
442    lines
443}
444
445/// Scrollable LLM review history (newest first).
446pub fn llm_history_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
447    if !ctx.rules.llm.enabled {
448        return vec![Line::from("LLM disabled in rules")];
449    }
450
451    let reviews: Vec<_> = ctx
452        .state
453        .last_actions
454        .iter()
455        .rev()
456        .filter(|a| matches!(a.action.as_str(), "llm_review" | "overnight_digest"))
457        .take(10)
458        .collect();
459
460    if reviews.is_empty() {
461        if let Some(summary) = ctx.state.last_llm_summary.as_ref() {
462            return format_llm_review_lines(summary, Some("latest"));
463        }
464        return vec![Line::from(Span::styled(
465            "No LLM reviews yet",
466            Style::default().fg(Color::DarkGray),
467        ))];
468    }
469
470    let mut out = Vec::new();
471    for (i, act) in reviews.iter().enumerate() {
472        if i > 0 {
473            out.push(Line::from("────────────────────────────────────────"));
474        }
475        let stamp = act.at.format("%Y-%m-%d %H:%M").to_string();
476        out.extend(format_llm_review_lines(&act.detail, Some(&stamp)));
477    }
478    out
479}
480
481fn latest_llm_review(ctx: &DashboardContext) -> Option<&serde_json::Value> {
482    ctx.state
483        .last_actions
484        .iter()
485        .rev()
486        .find(|a| matches!(a.action.as_str(), "llm_review" | "overnight_digest"))
487        .map(|a| &a.detail)
488        .or(ctx.state.last_llm_summary.as_ref())
489}
490
491fn format_llm_review_lines(
492    review: &serde_json::Value,
493    timestamp: Option<&str>,
494) -> Vec<Line<'static>> {
495    let mut lines = Vec::new();
496
497    let phase = review
498        .get("phase")
499        .and_then(|v| v.as_str())
500        .unwrap_or("review");
501    let phase_label = match phase {
502        "overnight_digest" => "overnight digest",
503        other => other,
504    };
505    let model = review
506        .get("model")
507        .and_then(|v| v.as_str())
508        .unwrap_or("model");
509    let web = review
510        .get("used_web")
511        .and_then(|v| v.as_bool())
512        .unwrap_or(false);
513
514    let mut header = format!("{phase_label} ({model}");
515    if web {
516        header.push_str(" + web");
517    }
518    header.push(')');
519    if let Some(ts) = timestamp {
520        header = format!("{ts}  {header}");
521    }
522    lines.push(Line::from(vec![Span::styled(
523        header,
524        Style::default()
525            .fg(Color::Yellow)
526            .add_modifier(Modifier::BOLD),
527    )]));
528
529    if let Some(rec) = review
530        .pointer("/new_entries/recommendation")
531        .and_then(|v| v.as_str())
532    {
533        let style = match rec {
534            "proceed" => Style::default().fg(Color::Green),
535            "defer" | "skip" => Style::default().fg(Color::Yellow),
536            _ => Style::default().fg(Color::Cyan),
537        };
538        lines.push(Line::from(vec![
539            Span::styled("  entries: ", Style::default().fg(Color::DarkGray)),
540            Span::styled(rec.to_string(), style),
541        ]));
542    }
543
544    if let Some(reason) = review
545        .pointer("/new_entries/reasoning")
546        .and_then(|v| v.as_str())
547    {
548        if !reason.is_empty() {
549            lines.push(Line::from(vec![
550                Span::styled("  why: ", Style::default().fg(Color::DarkGray)),
551                Span::raw(reason.to_string()),
552            ]));
553        }
554    }
555
556    if let Some(commentary) = review.get("market_commentary").and_then(|v| v.as_str()) {
557        if !commentary.is_empty() {
558            lines.push(Line::from(vec![
559                Span::styled("  market: ", Style::default().fg(Color::DarkGray)),
560                Span::raw(commentary.to_string()),
561            ]));
562        }
563    }
564
565    if let Some(alerts) = review.get("risk_alerts").and_then(|v| v.as_array()) {
566        for alert in alerts {
567            if let Some(s) = alert.as_str() {
568                lines.push(Line::from(vec![
569                    Span::styled("  alert: ", Style::default().fg(Color::Red)),
570                    Span::raw(s.to_string()),
571                ]));
572            }
573        }
574    }
575
576    if let Some(positions) = review.get("positions").and_then(|v| v.as_array()) {
577        for pos in positions {
578            let id = pos
579                .get("position_id")
580                .and_then(|v| v.as_str())
581                .unwrap_or("?");
582            let rec = pos
583                .get("recommendation")
584                .and_then(|v| v.as_str())
585                .unwrap_or("hold");
586            let urgency = pos.get("urgency").and_then(|v| v.as_str()).unwrap_or("");
587            let urg = if urgency.is_empty() {
588                String::new()
589            } else {
590                format!(" ({urgency})")
591            };
592            lines.push(Line::from(format!("    • {id}: {rec}{urg}")));
593        }
594    }
595
596    lines
597}
598
599pub fn rules_detail_lines(ctx: &DashboardContext) -> Vec<Line<'static>> {
600    let rules = &ctx.rules;
601    let mut out = Vec::new();
602
603    push_section(&mut out, "Accounts");
604    for a in rules.accounts.iter().filter(|a| a.enabled) {
605        let label = a.label.as_deref().unwrap_or("account");
606        let hash = &a.hash[..a.hash.len().min(8)];
607        out.push(Line::from(format!("  {label}  {hash}…  {:?}", a.r#type)));
608    }
609
610    push_section(&mut out, "Schedule");
611    let s = &rules.schedule;
612    out.push(kv_line(
613        "tick",
614        format!(
615            "{} ({})",
616            format_duration_secs(s.tick_interval_seconds),
617            s.tick_interval_seconds
618        ),
619        14,
620    ));
621    out.push(kv_line("timezone", s.timezone.clone(), 14));
622    out.push(kv_line(
623        "market hours",
624        if s.market_hours_only { "yes" } else { "no" }.into(),
625        14,
626    ));
627    if s.overnight.enabled {
628        out.push(kv_line(
629            "overnight",
630            format!(
631                "every {} · digest {}",
632                format_duration_secs(s.overnight.tick_interval_seconds),
633                if s.overnight.web_digest { "on" } else { "off" }
634            ),
635            14,
636        ));
637    } else {
638        out.push(kv_line("overnight", "disabled".into(), 14));
639    }
640
641    push_section(&mut out, "Entry");
642    if rules.strategies.vertical.enabled {
643        let v = &rules.entry_rules.vertical;
644        out.push(Line::from(format!("  vertical ({})", v.r#type)));
645        out.push(Line::from(format!(
646            "    DTE {}–{} · credit ≥ ${:.2} · width ${:.0}",
647            v.dte_min, v.dte_max, v.min_credit, v.max_width
648        )));
649        out.push(Line::from(format!(
650            "    δ {:.2}–{:.2} · max {} pos",
651            v.short_delta_min, v.short_delta_max, v.max_open_positions
652        )));
653    }
654    if rules.strategies.iron_condor.enabled {
655        let ic = &rules.entry_rules.iron_condor;
656        out.push(Line::from(format!(
657            "    iron condor {}–{} DTE · credit ≥ ${:.2}",
658            ic.dte_min, ic.dte_max, ic.min_credit
659        )));
660    }
661
662    push_section(&mut out, "Exit");
663    let ex = &rules.exit_rules;
664    out.push(kv_line(
665        "profit target",
666        format!("{}%", ex.profit_target_pct),
667        14,
668    ));
669    out.push(kv_line(
670        "stop loss",
671        format!("{}% credit", ex.stop_loss_pct),
672        14,
673    ));
674    out.push(kv_line("close DTE", format!("≤{}", ex.dte_close), 14));
675
676    push_section(&mut out, "Risk");
677    let risk = &rules.risk;
678    let used = ctx.portfolio_risk_usd();
679    out.push(kv_line(
680        "portfolio",
681        format!("${:.0} / ${:.0}", used, risk.max_portfolio_risk_usd),
682        14,
683    ));
684    out.push(kv_line(
685        "per trade",
686        format!("${:.0}", risk.max_risk_per_trade_usd),
687        14,
688    ));
689    out.push(kv_line(
690        "trades/day",
691        risk.max_trades_per_day.to_string(),
692        14,
693    ));
694    out.push(kv_line(
695        "allowed",
696        if risk.allowed_underlyings.is_empty() {
697            rules.watchlist.join(", ")
698        } else {
699            risk.allowed_underlyings.join(", ")
700        },
701        14,
702    ));
703
704    push_section(&mut out, "LLM");
705    let llm = &rules.llm;
706    if llm.enabled {
707        out.push(kv_line(
708            "selection",
709            llm.effective_selection_model().to_string(),
710            14,
711        ));
712        out.push(kv_line(
713            "monitor",
714            llm.effective_monitor_model().to_string(),
715            14,
716        ));
717        out.push(kv_line("web", llm.web_model.clone(), 14));
718        out.push(kv_line(
719            "monitor every",
720            format!(
721                "{} ticks (~{}m)",
722                llm.review_every_ticks,
723                ctx.monitor_interval_minutes()
724            ),
725            14,
726        ));
727        out.push(kv_line(
728            "web research",
729            format!("every {} reviews", llm.web_research_every_reviews),
730            14,
731        ));
732        out.push(kv_line(
733            "veto / exits",
734            format!("{} / {}", llm.veto_entries, llm.allow_llm_exits),
735            14,
736        ));
737    } else {
738        out.push(Line::from("  disabled"));
739    }
740
741    push_section(&mut out, "Execution");
742    let e = &rules.execution;
743    out.push(kv_line("order type", e.order_type.clone(), 14));
744    out.push(kv_line("preview", e.require_preview.to_string(), 14));
745    out.push(kv_line("wait fill", e.wait_for_fill.to_string(), 14));
746    out.push(kv_line(
747        "timeout",
748        format!("{}s", e.fill_timeout_seconds),
749        14,
750    ));
751
752    out
753}
754
755pub fn daemon_hint(_ctx: &DashboardContext) -> Line<'static> {
756    Line::from(vec![
757        Span::styled("! ", Style::default().fg(Color::Yellow)),
758        Span::raw("Agent not running — use "),
759        Span::styled(
760            "schwab watch --trust --yes",
761            Style::default().fg(Color::Cyan),
762        ),
763        Span::raw(" to run agent + TUI, or "),
764        Span::styled(
765            "schwab agent run … --background",
766            Style::default().fg(Color::Cyan),
767        ),
768        Span::raw(" for headless daemon"),
769    ])
770}
771
772fn push_section(out: &mut Vec<Line<'static>>, title: &str) {
773    out.push(Line::from(""));
774    out.push(Line::from(vec![Span::styled(
775        title.to_string(),
776        Style::default()
777            .fg(Color::Yellow)
778            .add_modifier(Modifier::BOLD),
779    )]));
780}
781
782fn kv_line(key: &str, value: String, key_width: usize) -> Line<'static> {
783    Line::from(vec![
784        Span::styled(
785            format!("  {key:width$}  ", width = key_width),
786            Style::default().fg(Color::DarkGray),
787        ),
788        Span::raw(value),
789    ])
790}
791
792fn short_model(model: &str) -> String {
793    model
794        .rsplit('/')
795        .next()
796        .unwrap_or(model)
797        .chars()
798        .take(18)
799        .collect()
800}
801
802fn format_action_detail(action: &str, detail: &serde_json::Value) -> String {
803    match action {
804        "llm_review" => detail
805            .get("phase")
806            .and_then(|v| v.as_str())
807            .map(|p| {
808                let rec = detail
809                    .pointer("/new_entries/recommendation")
810                    .and_then(|v| v.as_str())
811                    .unwrap_or("—");
812                format!("{p} → entries {rec}")
813            })
814            .unwrap_or_else(|| "review".into()),
815        "overnight_digest" => detail
816            .get("market_commentary")
817            .and_then(|v| v.as_str())
818            .map(str::to_string)
819            .unwrap_or_else(|| "digest".into()),
820        _ => action.to_string(),
821    }
822}