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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum WatchAgentMode {
40 Embedded,
42 External,
44 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}