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::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
390pub 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
418pub 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}