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