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