1use 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
417pub 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
445pub 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}