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