1use 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
399pub 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
427pub 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}