use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table, Wrap};
use crate::app::{App, Panel, Screen, SidebarSection};
pub fn draw(f: &mut Frame, app: &App) {
match app.screen {
Screen::Dashboard => draw_dashboard(f, app),
Screen::ShellTerminal(id) => draw_terminal(f, app, id),
Screen::AgentConversation(id) => draw_conversation(f, app, id),
Screen::PromptInput(id) => draw_prompt_input(f, app, id),
Screen::SpawnPicker => draw_spawn_picker(f, app),
}
}
fn draw_dashboard(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(f.area());
let version = env!("CARGO_PKG_VERSION");
let header = Line::from(vec![
Span::styled(
format!(" vex v{}", version),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("local:{}", app.port),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(
format!(
"{}S {}A {}R",
app.state.shells.len(),
app.state.agents.len(),
app.state.repos.len()
),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled("[?]help [q]quit", Style::default().fg(Color::DarkGray)),
]);
f.render_widget(Paragraph::new(header), chunks[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(18), Constraint::Min(0)])
.split(chunks[1]);
draw_sidebar(f, app, body[0]);
draw_main(f, app, body[1]);
let footer_text = if let Some(ref msg) = app.status_message {
msg.clone()
} else {
"[s]pawn [c]reate [K]ill [p]rompt [r]efresh".to_string()
};
let footer = Paragraph::new(Line::from(Span::styled(
format!(" {}", footer_text),
Style::default().fg(Color::DarkGray),
)));
f.render_widget(footer, chunks[2]);
}
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let highlight_style = if app.panel == Panel::Sidebar {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let sections = [
(SidebarSection::Repos, "Repos"),
(SidebarSection::Agents, "Agents"),
(SidebarSection::Shells, "Shells"),
(SidebarSection::Config, "Config"),
];
let mut items: Vec<ListItem> = Vec::new();
for (section, label) in §ions {
let is_active = app.sidebar_section == *section;
let prefix = if is_active { ">" } else { " " };
let style = if is_active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
items.push(ListItem::new(Line::from(Span::styled(
format!("{} [{}]", prefix, label),
style,
))));
if is_active {
let section_items = app.sidebar_items();
for (i, name) in section_items.iter().enumerate() {
let is_selected = i == app.sidebar_index;
let style = if is_selected {
highlight_style
} else {
Style::default().fg(Color::White)
};
let prefix = if is_selected { " >" } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, name),
style,
))));
}
}
}
let sidebar = List::new(items).block(
Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(sidebar, area);
}
fn draw_main(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_agents_table(f, app, chunks[0]);
draw_shells_table(f, app, chunks[1]);
}
fn draw_agents_table(f: &mut Frame, app: &App, area: Rect) {
let header = Row::new(vec![
Cell::from("ID"),
Cell::from("Status"),
Cell::from("CWD"),
])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = app
.state
.agents
.iter()
.map(|a| {
let short_id = &a.vex_shell_id.to_string()[..8];
let (status, style) = if a.needs_intervention {
("\u{25cc} NEEDS", Style::default().fg(Color::Yellow))
} else {
("\u{25cf} idle", Style::default().fg(Color::Green))
};
let cwd = a.cwd.display().to_string();
let short_cwd = if cwd.len() > 40 {
let tail: String = cwd
.chars()
.rev()
.take(37)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("...{}", tail)
} else {
cwd
};
Row::new(vec![
Cell::from(short_id.to_string()),
Cell::from(Span::styled(status.to_string(), style)),
Cell::from(short_cwd),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(10),
Constraint::Min(20),
],
)
.header(header)
.block(
Block::default()
.title(" AGENTS ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(table, area);
}
fn draw_shells_table(f: &mut Frame, app: &App, area: Rect) {
let header = Row::new(vec![
Cell::from("ID"),
Cell::from("Size"),
Cell::from("Clients"),
Cell::from("Created"),
])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let rows: Vec<Row> = app
.state
.shells
.iter()
.map(|s| {
let short_id = &s.id.to_string()[..8];
Row::new(vec![
Cell::from(short_id.to_string()),
Cell::from(format!("{}x{}", s.cols, s.rows)),
Cell::from(s.client_count.to_string()),
Cell::from(s.created_at.format("%H:%M:%S").to_string()),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Min(10),
],
)
.header(header)
.block(
Block::default()
.title(" SHELLS ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(table, area);
}
fn draw_terminal(f: &mut Frame, app: &App, shell_id: uuid::Uuid) {
let area = f.area();
let block = Block::default()
.title(format!(
" shell {} \u{2014} Ctrl+] to detach ",
&shell_id.to_string()[..8]
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
if let Some(vt) = app.vt_terminals.get(&shell_id) {
f.render_widget(vt.widget(), inner);
}
}
fn draw_conversation(f: &mut Frame, app: &App, shell_id: uuid::Uuid) {
let area = f.area();
let block = Block::default()
.title(format!(
" agent {} [p]rompt [q]back ",
&shell_id.to_string()[..8]
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let lines: Vec<Line> = app
.agent_lines
.get(&shell_id)
.map(|lines| {
lines
.iter()
.map(|l| Line::from(Span::raw(l.clone())))
.collect()
})
.unwrap_or_default();
let skip = if lines.len() > inner.height as usize {
lines.len() - inner.height as usize
} else {
0
};
let para = Paragraph::new(lines)
.scroll((skip as u16, 0))
.wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
fn draw_prompt_input(f: &mut Frame, app: &App, shell_id: uuid::Uuid) {
draw_dashboard(f, app);
let area = centered_rect(60, 5, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(format!(" Prompt agent {} ", &shell_id.to_string()[..8]))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let text = format!("> {}_", app.prompt_buf);
let para = Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(Color::White),
)));
f.render_widget(para, inner);
}
fn draw_spawn_picker(f: &mut Frame, app: &App) {
draw_dashboard(f, app);
let height = (app.state.repos.len() + 3).min(15) as u16;
let area = centered_rect(40, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" Spawn Agent — pick repo ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let items: Vec<ListItem> = app
.state
.repos
.iter()
.enumerate()
.map(|(i, r)| {
let style = if i == app.spawn_repo_index {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(r.name.clone(), style)))
})
.collect();
let list = List::new(items);
f.render_widget(list, inner);
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
let width = (area.width * percent_x / 100).min(area.width);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, width, height.min(area.height))
}