Skip to main content

schwab_cli/ui/
watch.rs

1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4use std::time::{Duration, Instant};
5
6use anyhow::Result;
7use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
8use crossterm::terminal::{
9    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
10};
11use crossterm::ExecutableCommand;
12use ratatui::backend::CrosstermBackend;
13use ratatui::layout::{Constraint, Direction, Layout, Rect};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{
17    Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs,
18    Wrap,
19};
20use ratatui::Terminal;
21
22use super::agent_health::SharedAgentHealth;
23use super::context::DashboardContext;
24use super::market_status::MarketSnapshot;
25use super::positions_panel::{positions_content_height, render_positions_panel, CARD_HEIGHT};
26use super::theme::{self, footer_block, key_style};
27use super::tui_render::{
28    activity_lines, agent_status_lines, daemon_hint, header_line, latest_llm_lines,
29    llm_history_lines, market_conditions_panel_lines, risk_gauge,
30    rules_detail_lines, rules_summary_lines,
31};
32use crate::market_conditions::MarketConditionsSnapshot;
33use crate::ui::spread_live::{list_spread_monitors, SpreadLiveSnapshot};
34
35const REFRESH_INTERVAL: Duration = Duration::from_secs(3);
36
37/// How the watch TUI relates to the agent process.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum WatchAgentMode {
40    /// Agent loop runs in the same process (default when no daemon exists).
41    Embedded,
42    /// Attached to an existing background daemon.
43    External,
44    /// User passed `--monitor-only`; no agent started by watch.
45    MonitorOnly,
46}
47
48#[derive(Debug, Clone)]
49pub struct WatchConfig {
50    pub rules_path: PathBuf,
51    pub agent_mode: WatchAgentMode,
52    pub market_snapshot: Arc<Mutex<MarketSnapshot>>,
53    pub market_conditions: Arc<Mutex<MarketConditionsSnapshot>>,
54    pub agent_health: Option<SharedAgentHealth>,
55    pub spread_snapshot: Arc<std::sync::RwLock<SpreadLiveSnapshot>>,
56}
57
58#[derive(Clone, Copy, PartialEq, Eq)]
59enum WatchTab {
60    Overview = 0,
61    Positions = 1,
62    Rules = 2,
63    Log = 3,
64    Llm = 4,
65}
66
67impl WatchTab {
68    fn all() -> [WatchTab; 5] {
69        [
70            WatchTab::Overview,
71            WatchTab::Positions,
72            WatchTab::Rules,
73            WatchTab::Log,
74            WatchTab::Llm,
75        ]
76    }
77
78    fn title(self) -> &'static str {
79        match self {
80            WatchTab::Overview => "Overview",
81            WatchTab::Positions => "Positions",
82            WatchTab::Rules => "Rules",
83            WatchTab::Log => "Log",
84            WatchTab::Llm => "LLM",
85        }
86    }
87
88    fn next(self) -> Self {
89        match self {
90            WatchTab::Overview => WatchTab::Positions,
91            WatchTab::Positions => WatchTab::Rules,
92            WatchTab::Rules => WatchTab::Log,
93            WatchTab::Log => WatchTab::Llm,
94            WatchTab::Llm => WatchTab::Overview,
95        }
96    }
97}
98
99struct WatchState {
100    rules_scroll: u16,
101    log_scroll: u16,
102    llm_scroll: u16,
103    positions_scroll: u16,
104}
105
106pub fn run_watch_tui(config: &WatchConfig) -> Result<()> {
107    enable_raw_mode()?;
108    let mut stdout = io::stdout();
109    stdout.execute(EnterAlternateScreen)?;
110    let backend = CrosstermBackend::new(stdout);
111    let mut terminal = Terminal::new(backend)?;
112    terminal.clear()?;
113
114    let rules_path = &config.rules_path;
115    let agent_mode = config.agent_mode;
116    let market_snapshot = &config.market_snapshot;
117    let market_conditions = &config.market_conditions;
118    let agent_health = config.agent_health.as_ref();
119    let spread_snapshot = &config.spread_snapshot;
120
121    let mut tab = WatchTab::Overview;
122    let mut ctx = DashboardContext::load_with_shared_snapshot(rules_path, market_snapshot)?;
123    let mut last_refresh = Instant::now();
124    let mut status_msg = match agent_mode {
125        WatchAgentMode::Embedded => "agent running in-process".to_string(),
126        WatchAgentMode::External => format!("attached to pid {}", ctx.daemon.pid.unwrap_or(0)),
127        WatchAgentMode::MonitorOnly => "monitor only".to_string(),
128    };
129    let mut state = WatchState {
130        rules_scroll: 0,
131        log_scroll: 0,
132        llm_scroll: 0,
133        positions_scroll: 0,
134    };
135
136    loop {
137        terminal.draw(|f| {
138            let live_spread = spread_snapshot.read().ok();
139            draw_ui(
140                f,
141                f.area(),
142                &ctx,
143                tab,
144                &status_msg,
145                &mut state,
146                agent_mode,
147                agent_health,
148                live_spread.as_deref(),
149                market_conditions,
150            );
151        })?;
152
153        let timeout = REFRESH_INTERVAL.saturating_sub(last_refresh.elapsed());
154        if event::poll(timeout)? {
155            if let Event::Key(key) = event::read()? {
156                if key.kind == KeyEventKind::Press {
157                    match key.code {
158                        KeyCode::Char('q') | KeyCode::Esc => break,
159                        KeyCode::Tab => tab = tab.next(),
160                        KeyCode::Char('1') => tab = WatchTab::Overview,
161                        KeyCode::Char('2') => tab = WatchTab::Positions,
162                        KeyCode::Char('3') => tab = WatchTab::Rules,
163                        KeyCode::Char('4') => tab = WatchTab::Log,
164                        KeyCode::Char('5') => tab = WatchTab::Llm,
165                        KeyCode::Char('j') | KeyCode::Down => {
166                            scroll_active_tab(tab, &mut state, 1, &ctx)
167                        }
168                        KeyCode::Char('k') | KeyCode::Up => {
169                            scroll_active_tab(tab, &mut state, -1, &ctx)
170                        }
171                        KeyCode::Char('r') => {
172                            match DashboardContext::load_with_shared_snapshot(
173                                rules_path,
174                                market_snapshot,
175                            ) {
176                                Ok(c) => {
177                                    ctx = c;
178                                    last_refresh = Instant::now();
179                                    status_msg = "refreshed".into();
180                                }
181                                Err(e) => status_msg = format!("refresh failed: {e:#}"),
182                            }
183                        }
184                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
185                            break
186                        }
187                        _ => {}
188                    }
189                }
190            }
191        } else if last_refresh.elapsed() >= REFRESH_INTERVAL {
192            if let Ok(c) = DashboardContext::load_with_shared_snapshot(rules_path, market_snapshot)
193            {
194                ctx = c;
195            }
196            last_refresh = Instant::now();
197        }
198    }
199
200    disable_raw_mode()?;
201    terminal.backend_mut().execute(LeaveAlternateScreen)?;
202    terminal.show_cursor()?;
203    Ok(())
204}
205
206fn scroll_active_tab(tab: WatchTab, state: &mut WatchState, delta: i16, ctx: &DashboardContext) {
207    let scroll = match tab {
208        WatchTab::Rules => &mut state.rules_scroll,
209        WatchTab::Log => &mut state.log_scroll,
210        WatchTab::Llm => &mut state.llm_scroll,
211        WatchTab::Positions => {
212            let monitors = list_spread_monitors(&ctx.rules, &ctx.state, None);
213            let max = positions_content_height(&monitors).saturating_sub(CARD_HEIGHT);
214            let s = &mut state.positions_scroll;
215            if delta < 0 {
216                *s = s.saturating_sub(delta.unsigned_abs());
217            } else {
218                *s = (*s + delta as u16).min(max);
219            }
220            return;
221        }
222        _ => return,
223    };
224    if delta < 0 {
225        *scroll = scroll.saturating_sub(delta.unsigned_abs());
226    } else {
227        *scroll = scroll.saturating_add(delta as u16);
228    }
229}
230
231#[allow(clippy::too_many_arguments)]
232fn draw_ui(
233    f: &mut ratatui::Frame,
234    area: Rect,
235    ctx: &DashboardContext,
236    tab: WatchTab,
237    status_msg: &str,
238    state: &mut WatchState,
239    agent_mode: WatchAgentMode,
240    agent_health: Option<&SharedAgentHealth>,
241    live_spread: Option<&SpreadLiveSnapshot>,
242    market_conditions: &Arc<Mutex<MarketConditionsSnapshot>>,
243) {
244    let exits_armed = agent_health
245        .and_then(|h| h.lock().ok())
246        .map(|g| g.exits_armed())
247        .unwrap_or(true);
248    let auth_required = agent_health
249        .and_then(|h| h.lock().ok())
250        .map(|g| g.auth_required)
251        .unwrap_or(false);
252    let loop_running = agent_health
253        .and_then(|h| h.lock().ok())
254        .map(|g| g.loop_running)
255        .unwrap_or(true);
256
257    let show_banner = matches!(agent_mode, WatchAgentMode::Embedded) && !exits_armed;
258    let mut constraints = vec![Constraint::Length(3)];
259    if show_banner {
260        constraints.push(Constraint::Length(3));
261    }
262    constraints.push(Constraint::Length(3));
263    constraints.push(Constraint::Min(6));
264    constraints.push(Constraint::Length(3));
265
266    let outer = Layout::default()
267        .direction(Direction::Vertical)
268        .constraints(constraints)
269        .split(area);
270
271    let mut idx = 0usize;
272    f.render_widget(
273        wrap_paragraph(header_line(ctx, agent_mode, agent_health))
274            .block(theme::chrome_block("Schwab Options")),
275        outer[idx],
276    );
277    idx += 1;
278
279    if show_banner {
280        let banner = if auth_required {
281            " AGENT DOWN (auth) — exits not executing — run: schwab auth login "
282        } else if !loop_running {
283            " AGENT DOWN — exits not executing — supervisor stopped "
284        } else {
285            " AGENT DEGRADED — exits not executing — retrying… "
286        };
287        f.render_widget(
288            Paragraph::new(Line::from(Span::styled(
289                banner,
290                Style::default()
291                    .fg(Color::Black)
292                    .bg(Color::Yellow)
293                    .add_modifier(Modifier::BOLD),
294            )))
295            .block(theme::footer_block()),
296            outer[idx],
297        );
298        idx += 1;
299    }
300
301    let titles: Vec<Line> = WatchTab::all()
302        .iter()
303        .enumerate()
304        .map(|(i, t)| {
305            Line::from(vec![
306                Span::styled(
307                    format!(" {} ", i + 1),
308                    Style::default().fg(theme::MUTED),
309                ),
310                Span::raw(t.title()),
311            ])
312        })
313        .collect();
314    let tabs = Tabs::new(titles)
315        .style(Style::default().fg(theme::MUTED))
316        .highlight_style(
317            Style::default()
318                .fg(Color::Yellow)
319                .add_modifier(Modifier::BOLD),
320        )
321        .divider("│")
322        .select(tab as usize)
323        .padding(" ", " ");
324    f.render_widget(tabs.block(theme::footer_block()), outer[idx]);
325    idx += 1;
326
327    let content = outer[idx];
328    idx += 1;
329    match tab {
330        WatchTab::Overview => render_overview(
331            f,
332            content,
333            ctx,
334            agent_mode,
335            agent_health,
336            market_conditions,
337        ),
338        WatchTab::Rules => render_rules_tab(f, content, ctx, state),
339        WatchTab::Log => render_log_tab(f, content, ctx, state),
340        WatchTab::Positions => {
341            render_positions_tab(f, content, ctx, live_spread, state, exits_armed)
342        }
343        WatchTab::Llm => render_llm_tab(f, content, ctx, state),
344    }
345
346    let footer = Line::from(vec![
347        Span::styled(" Tab ", key_style()),
348        Span::styled("/1-5 switch  ", theme::label_style()),
349        Span::styled("j/k ", key_style()),
350        Span::styled("scroll  ", theme::label_style()),
351        Span::styled("r ", key_style()),
352        Span::styled("refresh  ", theme::label_style()),
353        Span::styled("q ", key_style()),
354        Span::styled("quit  ", theme::label_style()),
355        Span::styled("│ ", theme::label_style()),
356        Span::styled(status_msg, theme::label_style()),
357    ]);
358    f.render_widget(
359        wrap_paragraph(footer).block(footer_block()),
360        outer[idx],
361    );
362}
363
364fn wrap_paragraph<'a>(content: impl Into<ratatui::text::Text<'a>>) -> Paragraph<'a> {
365    Paragraph::new(content).wrap(Wrap { trim: true })
366}
367
368fn panel_block(title: &str) -> Block<'_> {
369    theme::panel_block(title)
370}
371
372fn render_overview(
373    f: &mut ratatui::Frame,
374    area: Rect,
375    ctx: &DashboardContext,
376    agent_mode: WatchAgentMode,
377    agent_health: Option<&SharedAgentHealth>,
378    market_conditions: &Arc<Mutex<MarketConditionsSnapshot>>,
379) {
380    let show_hint = matches!(agent_mode, WatchAgentMode::MonitorOnly) && !ctx.daemon.running;
381    let main_constraints = if show_hint {
382        vec![
383            Constraint::Length(5),
384            Constraint::Length(9),
385            Constraint::Length(8),
386            Constraint::Min(4),
387            Constraint::Length(3),
388        ]
389    } else {
390        vec![
391            Constraint::Length(5),
392            Constraint::Length(9),
393            Constraint::Length(8),
394            Constraint::Min(4),
395        ]
396    };
397
398    let rows = Layout::default()
399        .direction(Direction::Vertical)
400        .constraints(main_constraints)
401        .split(area);
402
403    let conditions = market_conditions
404        .lock()
405        .ok()
406        .map(|g| g.clone())
407        .unwrap_or_default();
408    f.render_widget(
409        wrap_paragraph(market_conditions_panel_lines(&conditions))
410            .block(panel_block("Market")),
411        rows[0],
412    );
413
414    let top = Layout::default()
415        .direction(Direction::Horizontal)
416        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
417        .split(rows[1]);
418
419    f.render_widget(
420        wrap_paragraph(agent_status_lines(ctx, agent_mode, agent_health))
421            .block(panel_block("Agent")),
422        top[0],
423    );
424
425    let rules_area = Layout::default()
426        .direction(Direction::Vertical)
427        .constraints([Constraint::Min(3), Constraint::Length(3)])
428        .split(top[1]);
429
430    f.render_widget(
431        wrap_paragraph(rules_summary_lines(ctx)).block(panel_block("Rules")),
432        rules_area[0],
433    );
434    f.render_widget(
435        risk_gauge(ctx).block(Block::default().borders(Borders::NONE)),
436        rules_area[1],
437    );
438
439    f.render_widget(
440        wrap_paragraph(latest_llm_lines(ctx)).block(panel_block("Last LLM")),
441        rows[2],
442    );
443
444    f.render_widget(
445        wrap_paragraph(activity_lines(ctx)).block(panel_block("Recent Activity")),
446        rows[3],
447    );
448
449    if show_hint {
450        f.render_widget(wrap_paragraph(daemon_hint(ctx)), rows[4]);
451    }
452}
453
454fn render_rules_tab(
455    f: &mut ratatui::Frame,
456    area: Rect,
457    ctx: &DashboardContext,
458    state: &mut WatchState,
459) {
460    let lines = rules_detail_lines(ctx);
461    let line_count = lines.len() as u16;
462    let scroll = state.rules_scroll.min(line_count.saturating_sub(1));
463
464    let vertical = Layout::default()
465        .direction(Direction::Horizontal)
466        .constraints([Constraint::Min(0), Constraint::Length(1)])
467        .split(area);
468
469    f.render_widget(
470        wrap_paragraph(lines)
471            .scroll((scroll, 0))
472            .block(
473                panel_block("Rules Config").title_bottom(format!(" {} ", ctx.rules_path.display())),
474            ),
475        vertical[0],
476    );
477
478    let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
479    f.render_stateful_widget(
480        Scrollbar::new(ScrollbarOrientation::VerticalRight)
481            .begin_symbol(Some("↑"))
482            .end_symbol(Some("↓")),
483        vertical[1],
484        &mut sb,
485    );
486}
487
488fn render_log_tab(
489    f: &mut ratatui::Frame,
490    area: Rect,
491    ctx: &DashboardContext,
492    state: &mut WatchState,
493) {
494    let lines: Vec<Line> = if ctx.log_tail.is_empty() {
495        vec![Line::from("(no log yet)")]
496    } else {
497        ctx.log_tail
498            .iter()
499            .map(|l| Line::from(l.as_str()))
500            .collect()
501    };
502    let line_count = lines.len() as u16;
503    let scroll = state.log_scroll.min(line_count.saturating_sub(1));
504
505    let vertical = Layout::default()
506        .direction(Direction::Horizontal)
507        .constraints([Constraint::Min(0), Constraint::Length(1)])
508        .split(area);
509
510    f.render_widget(
511        wrap_paragraph(lines)
512            .style(Style::default().fg(Color::DarkGray))
513            .scroll((scroll, 0))
514            .block(
515                panel_block("Agent Log")
516                    .title_bottom(format!(" {} ", short_path(&ctx.daemon.log_file))),
517            ),
518        vertical[0],
519    );
520
521    let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
522    f.render_stateful_widget(
523        Scrollbar::new(ScrollbarOrientation::VerticalRight),
524        vertical[1],
525        &mut sb,
526    );
527}
528
529fn render_positions_tab(
530    f: &mut ratatui::Frame,
531    area: Rect,
532    ctx: &DashboardContext,
533    live_spread: Option<&SpreadLiveSnapshot>,
534    state: &WatchState,
535    exits_armed: bool,
536) {
537    let monitors = list_spread_monitors(&ctx.rules, &ctx.state, live_spread);
538    let spreads = monitors.len();
539    let contracts = ctx.state.total_contracts();
540    let title = if contracts > spreads as u32 {
541        format!("Positions ({spreads} spread · {contracts} ct)")
542    } else {
543        format!("Positions ({spreads})")
544    };
545
546    let vertical = Layout::default()
547        .direction(Direction::Horizontal)
548        .constraints([Constraint::Min(0), Constraint::Length(1)])
549        .split(area);
550
551    let inner = panel_block(&title).inner(vertical[0]);
552    f.render_widget(panel_block(&title), vertical[0]);
553    render_positions_panel(
554        f,
555        inner,
556        &monitors,
557        state.positions_scroll,
558        live_spread,
559        exits_armed,
560    );
561
562    let total = positions_content_height(&monitors);
563    if total > inner.height {
564        let mut sb = ScrollbarState::new(total as usize)
565            .position(state.positions_scroll as usize);
566        f.render_stateful_widget(
567            Scrollbar::new(ScrollbarOrientation::VerticalRight)
568                .begin_symbol(Some("↑"))
569                .end_symbol(Some("↓")),
570            vertical[1],
571            &mut sb,
572        );
573    }
574}
575
576fn render_llm_tab(
577    f: &mut ratatui::Frame,
578    area: Rect,
579    ctx: &DashboardContext,
580    state: &mut WatchState,
581) {
582    let lines = llm_history_lines(ctx);
583    let line_count = lines.len() as u16;
584    let scroll = state.llm_scroll.min(line_count.saturating_sub(1));
585
586    let vertical = Layout::default()
587        .direction(Direction::Horizontal)
588        .constraints([Constraint::Min(0), Constraint::Length(1)])
589        .split(area);
590
591    let model = if ctx.rules.llm.enabled {
592        format!(
593            " {} / {} ",
594            ctx.rules.llm.effective_monitor_model(),
595            ctx.rules.llm.effective_selection_model()
596        )
597    } else {
598        " disabled ".into()
599    };
600
601    f.render_widget(
602        wrap_paragraph(lines)
603            .scroll((scroll, 0))
604            .block(panel_block("LLM Reviews").title_bottom(model)),
605        vertical[0],
606    );
607
608    let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
609    f.render_stateful_widget(
610        Scrollbar::new(ScrollbarOrientation::VerticalRight)
611            .begin_symbol(Some("↑"))
612            .end_symbol(Some("↓")),
613        vertical[1],
614        &mut sb,
615    );
616}
617
618fn short_path(path: &Path) -> String {
619    path.file_name()
620        .map(|n| n.to_string_lossy().into_owned())
621        .unwrap_or_else(|| path.display().to_string())
622}