use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs,
Wrap,
};
use ratatui::Terminal;
use super::agent_health::SharedAgentHealth;
use super::context::DashboardContext;
use super::market_status::MarketSnapshot;
use super::positions_panel::{positions_content_height, render_positions_panel, CARD_HEIGHT};
use super::theme::{self, footer_block, key_style};
use super::tui_render::{
activity_lines, agent_status_lines, daemon_hint, header_line, latest_llm_lines,
llm_history_lines, market_conditions_panel_lines, risk_gauge,
rules_detail_lines, rules_summary_lines,
};
use crate::market_conditions::MarketConditionsSnapshot;
use crate::ui::spread_live::{list_spread_monitors, SpreadLiveSnapshot};
const REFRESH_INTERVAL: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchAgentMode {
Embedded,
External,
MonitorOnly,
}
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub rules_path: PathBuf,
pub agent_mode: WatchAgentMode,
pub market_snapshot: Arc<Mutex<MarketSnapshot>>,
pub market_conditions: Arc<Mutex<MarketConditionsSnapshot>>,
pub agent_health: Option<SharedAgentHealth>,
pub spread_snapshot: Arc<std::sync::RwLock<SpreadLiveSnapshot>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum WatchTab {
Overview = 0,
Positions = 1,
Rules = 2,
Log = 3,
Llm = 4,
}
impl WatchTab {
fn all() -> [WatchTab; 5] {
[
WatchTab::Overview,
WatchTab::Positions,
WatchTab::Rules,
WatchTab::Log,
WatchTab::Llm,
]
}
fn title(self) -> &'static str {
match self {
WatchTab::Overview => "Overview",
WatchTab::Positions => "Positions",
WatchTab::Rules => "Rules",
WatchTab::Log => "Log",
WatchTab::Llm => "LLM",
}
}
fn next(self) -> Self {
match self {
WatchTab::Overview => WatchTab::Positions,
WatchTab::Positions => WatchTab::Rules,
WatchTab::Rules => WatchTab::Log,
WatchTab::Log => WatchTab::Llm,
WatchTab::Llm => WatchTab::Overview,
}
}
}
struct WatchState {
rules_scroll: u16,
log_scroll: u16,
llm_scroll: u16,
positions_scroll: u16,
}
pub fn run_watch_tui(config: &WatchConfig) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let rules_path = &config.rules_path;
let agent_mode = config.agent_mode;
let market_snapshot = &config.market_snapshot;
let market_conditions = &config.market_conditions;
let agent_health = config.agent_health.as_ref();
let spread_snapshot = &config.spread_snapshot;
let mut tab = WatchTab::Overview;
let mut ctx = DashboardContext::load_with_shared_snapshot(rules_path, market_snapshot)?;
let mut last_refresh = Instant::now();
let mut status_msg = match agent_mode {
WatchAgentMode::Embedded => "agent running in-process".to_string(),
WatchAgentMode::External => format!("attached to pid {}", ctx.daemon.pid.unwrap_or(0)),
WatchAgentMode::MonitorOnly => "monitor only".to_string(),
};
let mut state = WatchState {
rules_scroll: 0,
log_scroll: 0,
llm_scroll: 0,
positions_scroll: 0,
};
loop {
terminal.draw(|f| {
let live_spread = spread_snapshot.read().ok();
draw_ui(
f,
f.area(),
&ctx,
tab,
&status_msg,
&mut state,
agent_mode,
agent_health,
live_spread.as_deref(),
market_conditions,
);
})?;
let timeout = REFRESH_INTERVAL.saturating_sub(last_refresh.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Tab => tab = tab.next(),
KeyCode::Char('1') => tab = WatchTab::Overview,
KeyCode::Char('2') => tab = WatchTab::Positions,
KeyCode::Char('3') => tab = WatchTab::Rules,
KeyCode::Char('4') => tab = WatchTab::Log,
KeyCode::Char('5') => tab = WatchTab::Llm,
KeyCode::Char('j') | KeyCode::Down => {
scroll_active_tab(tab, &mut state, 1, &ctx)
}
KeyCode::Char('k') | KeyCode::Up => {
scroll_active_tab(tab, &mut state, -1, &ctx)
}
KeyCode::Char('r') => {
match DashboardContext::load_with_shared_snapshot(
rules_path,
market_snapshot,
) {
Ok(c) => {
ctx = c;
last_refresh = Instant::now();
status_msg = "refreshed".into();
}
Err(e) => status_msg = format!("refresh failed: {e:#}"),
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
break
}
_ => {}
}
}
}
} else if last_refresh.elapsed() >= REFRESH_INTERVAL {
if let Ok(c) = DashboardContext::load_with_shared_snapshot(rules_path, market_snapshot)
{
ctx = c;
}
last_refresh = Instant::now();
}
}
disable_raw_mode()?;
terminal.backend_mut().execute(LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn scroll_active_tab(tab: WatchTab, state: &mut WatchState, delta: i16, ctx: &DashboardContext) {
let scroll = match tab {
WatchTab::Rules => &mut state.rules_scroll,
WatchTab::Log => &mut state.log_scroll,
WatchTab::Llm => &mut state.llm_scroll,
WatchTab::Positions => {
let monitors = list_spread_monitors(&ctx.rules, &ctx.state, None);
let max = positions_content_height(&monitors).saturating_sub(CARD_HEIGHT);
let s = &mut state.positions_scroll;
if delta < 0 {
*s = s.saturating_sub(delta.unsigned_abs());
} else {
*s = (*s + delta as u16).min(max);
}
return;
}
_ => return,
};
if delta < 0 {
*scroll = scroll.saturating_sub(delta.unsigned_abs());
} else {
*scroll = scroll.saturating_add(delta as u16);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_ui(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
tab: WatchTab,
status_msg: &str,
state: &mut WatchState,
agent_mode: WatchAgentMode,
agent_health: Option<&SharedAgentHealth>,
live_spread: Option<&SpreadLiveSnapshot>,
market_conditions: &Arc<Mutex<MarketConditionsSnapshot>>,
) {
let exits_armed = agent_health
.and_then(|h| h.lock().ok())
.map(|g| g.exits_armed())
.unwrap_or(true);
let auth_required = agent_health
.and_then(|h| h.lock().ok())
.map(|g| g.auth_required)
.unwrap_or(false);
let loop_running = agent_health
.and_then(|h| h.lock().ok())
.map(|g| g.loop_running)
.unwrap_or(true);
let show_banner = matches!(agent_mode, WatchAgentMode::Embedded) && !exits_armed;
let mut constraints = vec![Constraint::Length(3)];
if show_banner {
constraints.push(Constraint::Length(3));
}
constraints.push(Constraint::Length(3));
constraints.push(Constraint::Min(6));
constraints.push(Constraint::Length(3));
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut idx = 0usize;
f.render_widget(
wrap_paragraph(header_line(ctx, agent_mode, agent_health))
.block(theme::chrome_block("Schwab Options")),
outer[idx],
);
idx += 1;
if show_banner {
let banner = if auth_required {
" AGENT DOWN (auth) — exits not executing — run: schwab auth login "
} else if !loop_running {
" AGENT DOWN — exits not executing — supervisor stopped "
} else {
" AGENT DEGRADED — exits not executing — retrying… "
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
banner,
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)))
.block(theme::footer_block()),
outer[idx],
);
idx += 1;
}
let titles: Vec<Line> = WatchTab::all()
.iter()
.enumerate()
.map(|(i, t)| {
Line::from(vec![
Span::styled(
format!(" {} ", i + 1),
Style::default().fg(theme::MUTED),
),
Span::raw(t.title()),
])
})
.collect();
let tabs = Tabs::new(titles)
.style(Style::default().fg(theme::MUTED))
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.divider("│")
.select(tab as usize)
.padding(" ", " ");
f.render_widget(tabs.block(theme::footer_block()), outer[idx]);
idx += 1;
let content = outer[idx];
idx += 1;
match tab {
WatchTab::Overview => render_overview(
f,
content,
ctx,
agent_mode,
agent_health,
market_conditions,
),
WatchTab::Rules => render_rules_tab(f, content, ctx, state),
WatchTab::Log => render_log_tab(f, content, ctx, state),
WatchTab::Positions => {
render_positions_tab(f, content, ctx, live_spread, state, exits_armed)
}
WatchTab::Llm => render_llm_tab(f, content, ctx, state),
}
let footer = Line::from(vec![
Span::styled(" Tab ", key_style()),
Span::styled("/1-5 switch ", theme::label_style()),
Span::styled("j/k ", key_style()),
Span::styled("scroll ", theme::label_style()),
Span::styled("r ", key_style()),
Span::styled("refresh ", theme::label_style()),
Span::styled("q ", key_style()),
Span::styled("quit ", theme::label_style()),
Span::styled("│ ", theme::label_style()),
Span::styled(status_msg, theme::label_style()),
]);
f.render_widget(
wrap_paragraph(footer).block(footer_block()),
outer[idx],
);
}
fn wrap_paragraph<'a>(content: impl Into<ratatui::text::Text<'a>>) -> Paragraph<'a> {
Paragraph::new(content).wrap(Wrap { trim: true })
}
fn panel_block(title: &str) -> Block<'_> {
theme::panel_block(title)
}
fn render_overview(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
agent_mode: WatchAgentMode,
agent_health: Option<&SharedAgentHealth>,
market_conditions: &Arc<Mutex<MarketConditionsSnapshot>>,
) {
let show_hint = matches!(agent_mode, WatchAgentMode::MonitorOnly) && !ctx.daemon.running;
let main_constraints = if show_hint {
vec![
Constraint::Length(5),
Constraint::Length(9),
Constraint::Length(8),
Constraint::Min(4),
Constraint::Length(3),
]
} else {
vec![
Constraint::Length(5),
Constraint::Length(9),
Constraint::Length(8),
Constraint::Min(4),
]
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(main_constraints)
.split(area);
let conditions = market_conditions
.lock()
.ok()
.map(|g| g.clone())
.unwrap_or_default();
f.render_widget(
wrap_paragraph(market_conditions_panel_lines(&conditions))
.block(panel_block("Market")),
rows[0],
);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
f.render_widget(
wrap_paragraph(agent_status_lines(ctx, agent_mode, agent_health))
.block(panel_block("Agent")),
top[0],
);
let rules_area = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(top[1]);
f.render_widget(
wrap_paragraph(rules_summary_lines(ctx)).block(panel_block("Rules")),
rules_area[0],
);
f.render_widget(
risk_gauge(ctx).block(Block::default().borders(Borders::NONE)),
rules_area[1],
);
f.render_widget(
wrap_paragraph(latest_llm_lines(ctx)).block(panel_block("Last LLM")),
rows[2],
);
f.render_widget(
wrap_paragraph(activity_lines(ctx)).block(panel_block("Recent Activity")),
rows[3],
);
if show_hint {
f.render_widget(wrap_paragraph(daemon_hint(ctx)), rows[4]);
}
}
fn render_rules_tab(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
state: &mut WatchState,
) {
let lines = rules_detail_lines(ctx);
let line_count = lines.len() as u16;
let scroll = state.rules_scroll.min(line_count.saturating_sub(1));
let vertical = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
f.render_widget(
wrap_paragraph(lines)
.scroll((scroll, 0))
.block(
panel_block("Rules Config").title_bottom(format!(" {} ", ctx.rules_path.display())),
),
vertical[0],
);
let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
vertical[1],
&mut sb,
);
}
fn render_log_tab(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
state: &mut WatchState,
) {
let lines: Vec<Line> = if ctx.log_tail.is_empty() {
vec![Line::from("(no log yet)")]
} else {
ctx.log_tail
.iter()
.map(|l| Line::from(l.as_str()))
.collect()
};
let line_count = lines.len() as u16;
let scroll = state.log_scroll.min(line_count.saturating_sub(1));
let vertical = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
f.render_widget(
wrap_paragraph(lines)
.style(Style::default().fg(Color::DarkGray))
.scroll((scroll, 0))
.block(
panel_block("Agent Log")
.title_bottom(format!(" {} ", short_path(&ctx.daemon.log_file))),
),
vertical[0],
);
let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
vertical[1],
&mut sb,
);
}
fn render_positions_tab(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
live_spread: Option<&SpreadLiveSnapshot>,
state: &WatchState,
exits_armed: bool,
) {
let monitors = list_spread_monitors(&ctx.rules, &ctx.state, live_spread);
let spreads = monitors.len();
let contracts = ctx.state.total_contracts();
let title = if contracts > spreads as u32 {
format!("Positions ({spreads} spread · {contracts} ct)")
} else {
format!("Positions ({spreads})")
};
let vertical = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let inner = panel_block(&title).inner(vertical[0]);
f.render_widget(panel_block(&title), vertical[0]);
render_positions_panel(
f,
inner,
&monitors,
state.positions_scroll,
live_spread,
exits_armed,
);
let total = positions_content_height(&monitors);
if total > inner.height {
let mut sb = ScrollbarState::new(total as usize)
.position(state.positions_scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
vertical[1],
&mut sb,
);
}
}
fn render_llm_tab(
f: &mut ratatui::Frame,
area: Rect,
ctx: &DashboardContext,
state: &mut WatchState,
) {
let lines = llm_history_lines(ctx);
let line_count = lines.len() as u16;
let scroll = state.llm_scroll.min(line_count.saturating_sub(1));
let vertical = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let model = if ctx.rules.llm.enabled {
format!(
" {} / {} ",
ctx.rules.llm.effective_monitor_model(),
ctx.rules.llm.effective_selection_model()
)
} else {
" disabled ".into()
};
f.render_widget(
wrap_paragraph(lines)
.scroll((scroll, 0))
.block(panel_block("LLM Reviews").title_bottom(model)),
vertical[0],
);
let mut sb = ScrollbarState::new(line_count as usize).position(scroll as usize);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
vertical[1],
&mut sb,
);
}
fn short_path(path: &Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}