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