Skip to main content

schwab_cli/ui/
dashboard.rs

1use chrono::{DateTime, Utc};
2use console::Style;
3
4use super::context::DashboardContext;
5use super::{
6    ago_secs, bar, clock_dot, format_duration_secs, panel, panel_fit, rule, status_dot,
7    terminal_width, two_column,
8};
9
10pub fn render_dashboard(ctx: &DashboardContext) -> String {
11    let width = terminal_width().min(120);
12    let use_side_by_side = width >= 100;
13    let col_inner = if use_side_by_side {
14        (width / 2).saturating_sub(4)
15    } else {
16        width.saturating_sub(4)
17    };
18    let full_max = width;
19
20    let now = chrono::Local::now().format("%a %d %b %Y  %H:%M");
21    let mut out = String::new();
22    out.push('\n');
23    out.push_str(&rule(&format!("✦ Schwab Agent  ·  {now}")));
24    out.push_str("\n\n");
25
26    let agent_panel = render_agent_panel(ctx, col_inner);
27    let rules_panel = render_rules_panel(ctx, col_inner);
28    if use_side_by_side {
29        out.push_str(&two_column(agent_panel, rules_panel, width));
30    } else {
31        out.push_str(&agent_panel);
32        out.push('\n');
33        out.push_str(&rules_panel);
34    }
35    out.push_str("\n\n");
36
37    let positions = render_positions_panel(ctx, full_max);
38    if !positions.is_empty() {
39        out.push_str(&positions);
40        out.push_str("\n\n");
41    }
42
43    let activity = render_activity_panel(ctx, full_max);
44    if !activity.is_empty() {
45        out.push_str(&activity);
46        out.push_str("\n\n");
47    }
48
49    let log_panel = render_log_panel(ctx, full_max);
50    if !log_panel.is_empty() {
51        out.push_str(&log_panel);
52        out.push_str("\n\n");
53    }
54
55    if !ctx.daemon.running {
56        let rules_display = ctx
57            .rules_path
58            .file_name()
59            .map(|n| format!("rules/{}", n.to_string_lossy()))
60            .unwrap_or_else(|| ctx.rules.agent_id.clone());
61        out.push_str(&format!(
62            "  {} Agent daemon is not running — {} {}\n\n",
63            Style::new().yellow().apply_to("!"),
64            Style::new().dim().apply_to("start with"),
65            Style::new().cyan().apply_to(format!(
66                "schwab agent run {rules_display} --background --trust --yes"
67            ))
68        ));
69    }
70
71    out.push_str(&render_footer_hint());
72    out.push('\n');
73    out
74}
75
76fn render_agent_panel(ctx: &DashboardContext, inner: usize) -> String {
77    let dim = Style::new().dim();
78    let green = Style::new().green();
79    let mut lines = Vec::new();
80
81    if ctx.daemon.running {
82        let pid = ctx
83            .daemon
84            .pid
85            .map(|p| p.to_string())
86            .unwrap_or_else(|| "?".into());
87        lines.push(format!(
88            "  {}  {:<12} {}  pid {}",
89            status_dot(true),
90            "daemon",
91            green.apply_to("running"),
92            dim.apply_to(pid)
93        ));
94    } else {
95        lines.push(format!(
96            "  {}  {:<12} {}",
97            status_dot(false),
98            "daemon",
99            Style::new().red().apply_to("stopped")
100        ));
101    }
102
103    if let Some(at) = ctx.state.last_tick {
104        let secs = (Utc::now() - at).num_seconds();
105        let session = ctx.state.last_session.as_deref().unwrap_or("?");
106        lines.push(format!(
107            "  {}  {:<12} last tick {}  {}",
108            clock_dot(),
109            "agent",
110            dim.apply_to(ago_secs(secs)),
111            session_style(session).apply_to(session)
112        ));
113    } else {
114        lines.push(format!(
115            "  {}  {:<12} {}",
116            clock_dot(),
117            "agent",
118            dim.apply_to("no ticks yet")
119        ));
120    }
121
122    let spreads = ctx.state.open_positions.len();
123    let contracts = ctx.state.total_contracts();
124    let pending = ctx.state.pending_count();
125    let trades = ctx.state.trades_today;
126    let max_trades = ctx.rules.risk.max_trades_per_day;
127    let pos_label = if contracts > spreads as u32 {
128        format!("{spreads} spread · {contracts} ct")
129    } else if spreads > 0 {
130        format!("{spreads} spread")
131    } else {
132        "flat".into()
133    };
134    lines.push(format!(
135        "           {pos_label}  ·  {pending} pending  ·  {trades}/{max_trades} trades today",
136    ));
137
138    if ctx.rules.llm.enabled {
139        let phase = ctx
140            .state
141            .last_llm_summary
142            .as_ref()
143            .and_then(|v| v.get("phase"))
144            .and_then(|v| v.as_str())
145            .unwrap_or("—");
146        lines.push(format!(
147            "           LLM {}  ·  {} reviews",
148            dim.apply_to(phase),
149            ctx.state.llm_review_count
150        ));
151    }
152
153    panel("Agent", &lines, inner)
154}
155
156fn render_rules_panel(ctx: &DashboardContext, inner: usize) -> String {
157    let dim = Style::new().dim();
158    let rules = &ctx.rules;
159    let mut lines = Vec::new();
160
161    lines.push(format!(
162        "  {}",
163        Style::new().cyan().bold().apply_to(&rules.agent_id)
164    ));
165    lines.push(format!(
166        "  tick {}  ·  monitor every {}m",
167        format_duration_secs(rules.schedule.tick_interval_seconds),
168        ctx.monitor_interval_minutes()
169    ));
170
171    let watch = rules.watchlist.join(", ");
172    lines.push(format!("  watchlist  {}", dim.apply_to(&watch)));
173
174    let mut strategies = Vec::new();
175    if rules.strategies.vertical.enabled {
176        let v = &rules.entry_rules.vertical;
177        strategies.push(format!(
178            "vertical {}  {}–{} DTE",
179            v.r#type, v.dte_min, v.dte_max
180        ));
181    }
182    if rules.strategies.iron_condor.enabled {
183        let ic = &rules.entry_rules.iron_condor;
184        strategies.push(format!("iron condor  {}–{} DTE", ic.dte_min, ic.dte_max));
185    }
186    if strategies.is_empty() {
187        lines.push(format!("  strategies {}", dim.apply_to("(none enabled)")));
188    } else {
189        for s in strategies {
190            lines.push(format!("  {s}"));
191        }
192    }
193
194    let risk_used = ctx.portfolio_risk_usd();
195    let risk_max = rules.risk.max_portfolio_risk_usd.max(1.0);
196    let risk_ratio = risk_used / risk_max;
197    lines.push(format!(
198        "  risk  ${:.0}/${:.0}  {}",
199        risk_used,
200        risk_max,
201        bar(risk_ratio, 8)
202    ));
203
204    if rules.llm.enabled {
205        lines.push(format!(
206            "  LLM  {} / {}",
207            short_model(rules.llm.effective_selection_model()),
208            short_model(rules.llm.effective_monitor_model())
209        ));
210    } else {
211        lines.push(format!("  LLM  {}", dim.apply_to("disabled")));
212    }
213
214    if rules.schedule.overnight.enabled {
215        lines.push(format!(
216            "  overnight  every {}  {}",
217            format_duration_secs(rules.schedule.overnight.tick_interval_seconds),
218            if rules.schedule.overnight.web_digest {
219                "web digest"
220            } else {
221                "no digest"
222            }
223        ));
224    }
225
226    panel("Rules", &lines, inner)
227}
228
229fn render_positions_panel(ctx: &DashboardContext, max_width: usize) -> String {
230    if ctx.state.open_positions.is_empty() {
231        return String::new();
232    }
233
234    let hold = Style::new().green();
235    let mut lines = Vec::new();
236    for pos in ctx.state.open_positions.values() {
237        let contracts = if pos.contracts > 1 {
238            format!(" ×{}", pos.contracts)
239        } else {
240            String::new()
241        };
242        let credit = pos
243            .entry_credit
244            .map(|c| format!("  cr ${c:.2}"))
245            .unwrap_or_default();
246        let opened = ago_from_dt(pos.opened_at);
247        lines.push(format!(
248            "  {}{}  {}  exp {}  max loss ${:.0}{}  — {}  {}",
249            pos.underlying,
250            contracts,
251            pos.strategy,
252            pos.expiry,
253            pos.max_loss_usd,
254            credit,
255            hold.apply_to("holding"),
256            Style::new().dim().apply_to(opened)
257        ));
258    }
259    let contracts = ctx.state.total_contracts();
260    let title = if contracts > ctx.state.open_positions.len() as u32 {
261        format!(
262            "Positions ({} spread · {} ct)",
263            ctx.state.open_positions.len(),
264            contracts
265        )
266    } else {
267        format!("Positions ({})", ctx.state.open_positions.len())
268    };
269    panel_fit(&title, &lines, max_width)
270}
271
272fn render_activity_panel(ctx: &DashboardContext, max_width: usize) -> String {
273    let actions: Vec<_> = ctx.state.last_actions.iter().rev().take(8).collect();
274    if actions.is_empty() {
275        return String::new();
276    }
277
278    let dim = Style::new().dim();
279    let mut lines = Vec::new();
280    for act in actions {
281        let time = act.at.format("%H:%M").to_string();
282        let detail = format_action_detail(&act.action, &act.detail);
283        lines.push(format!(
284            "  {}  {:<16} {}",
285            dim.apply_to(time),
286            Style::new().bold().apply_to(&act.action),
287            detail
288        ));
289    }
290    panel_fit("Recent Activity", &lines, max_width)
291}
292
293fn render_log_panel(ctx: &DashboardContext, max_width: usize) -> String {
294    if ctx.log_tail.is_empty() {
295        return String::new();
296    }
297    let dim = Style::new().dim();
298    let lines: Vec<String> = ctx
299        .log_tail
300        .iter()
301        .map(|l| {
302            let max_content = max_width.saturating_sub(6);
303            let trimmed = if strip_log_len(l) > max_content {
304                format!("{}…", truncate_visible(l, max_content.saturating_sub(1)))
305            } else {
306                l.clone()
307            };
308            format!("  {}", dim.apply_to(trimmed))
309        })
310        .collect();
311    panel_fit("Agent Log (tail)", &lines, max_width)
312}
313
314fn strip_log_len(s: &str) -> usize {
315    console::strip_ansi_codes(s).len()
316}
317
318fn truncate_visible(s: &str, max: usize) -> String {
319    s.chars().take(max).collect()
320}
321
322fn render_footer_hint() -> String {
323    Style::new()
324        .dim()
325        .apply_to(
326            "  schwab  dashboard · watch · rules show · agent status · agent run · agent stop · help",
327        )
328        .to_string()
329}
330
331fn format_action_detail(action: &str, detail: &serde_json::Value) -> String {
332    match action {
333        "llm_review" => detail
334            .get("phase")
335            .and_then(|v| v.as_str())
336            .map(|p| {
337                let rec = detail
338                    .pointer("/new_entries/recommendation")
339                    .and_then(|v| v.as_str())
340                    .unwrap_or("—");
341                format!("{p} → entries {rec}")
342            })
343            .unwrap_or_else(|| "review".into()),
344        "overnight_digest" => detail
345            .get("market_commentary")
346            .and_then(|v| v.as_str())
347            .map(|s| {
348                let t = s.chars().take(60).collect::<String>();
349                if s.len() > 60 {
350                    format!("{t}…")
351                } else {
352                    t
353                }
354            })
355            .unwrap_or_else(|| "digest".into()),
356        other => {
357            let s = detail.to_string();
358            if s.len() > 50 {
359                format!("{other} …")
360            } else {
361                s
362            }
363        }
364    }
365}
366
367fn session_style(session: &str) -> Style {
368    match session {
369        "regular" => Style::new().green(),
370        "overnight" => Style::new().magenta(),
371        "idle" => Style::new().dim(),
372        _ => Style::new().dim(),
373    }
374}
375
376fn short_model(model: &str) -> String {
377    model
378        .rsplit('/')
379        .next()
380        .unwrap_or(model)
381        .chars()
382        .take(20)
383        .collect()
384}
385
386fn ago_from_dt(dt: DateTime<Utc>) -> String {
387    ago_secs((Utc::now() - dt).num_seconds())
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use std::path::PathBuf;
394
395    #[test]
396    fn dashboard_renders_for_project_rules() {
397        let path =
398            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-rules.example.yaml");
399        if !path.exists() {
400            return;
401        }
402        let ctx = DashboardContext::load(&path).unwrap();
403        let out = render_dashboard(&ctx);
404        assert!(out.contains("Schwab Agent"));
405        assert!(out.contains(&ctx.rules.agent_id));
406    }
407}