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