Skip to main content

schwab_cli/ui/
tui_render.rs

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