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, Clear, List, ListItem, Paragraph, Wrap};
use crate::app::{App, Dialog, RightPane, SidebarEntry};
pub fn draw(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());
draw_header(f, app, chunks[0]);
draw_body(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
if app.dialog != Dialog::None {
draw_dialog(f, app);
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
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!(":{}", 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),
),
]);
f.render_widget(Paragraph::new(header), area);
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let text = if let Some(ref msg) = app.status_message {
msg.clone()
} else {
"Enter:open s:shell x:kill c:agent p:prompt R:repo w:ws r:refresh ?:help"
.to_string()
};
let footer = Paragraph::new(Line::from(Span::styled(
format!(" {}", text),
Style::default().fg(Color::DarkGray),
)));
f.render_widget(footer, area);
}
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
let has_right = app.right_pane != RightPane::Empty;
let constraints = if has_right {
vec![Constraint::Length(28), Constraint::Min(0)]
} else {
vec![Constraint::Percentage(100)]
};
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
draw_sidebar(f, app, body[0]);
if has_right && body.len() > 1 {
draw_right_pane(f, app, body[1]);
}
}
fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) {
let entries = app.sidebar();
let mut items: Vec<ListItem> = Vec::new();
for (i, entry) in entries.iter().enumerate() {
let is_cursor = i == app.cursor;
match entry {
SidebarEntry::RepoHeader { name } => {
items.push(ListItem::new(Line::from(Span::styled(
format!(" {}", name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))));
}
SidebarEntry::WorkstreamItem { name, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, name),
style,
))));
}
SidebarEntry::NestedAgent { label, needs, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if *needs {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, label),
style,
))));
}
SidebarEntry::NestedShell { label, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, label),
style,
))));
}
SidebarEntry::Divider => {
items.push(ListItem::new(Line::from(Span::styled(
" ───────────────────",
Style::default().fg(Color::DarkGray),
))));
}
SidebarEntry::AgentSectionHeader => {
items.push(ListItem::new(Line::from(Span::styled(
" Agents",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))));
}
SidebarEntry::AgentItem { label, needs, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if *needs {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, label),
style,
))));
}
SidebarEntry::CreateAgentPlaceholder => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}+ Create Agent", prefix),
style,
))));
}
SidebarEntry::ShellSectionHeader => {
items.push(ListItem::new(Line::from(Span::styled(
" Shells",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))));
}
SidebarEntry::ShellItem { label, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, label),
style,
))));
}
}
}
let border_style = if app.right_pane != RightPane::Empty {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Reset)
};
let sidebar = List::new(items).block(
Block::default()
.borders(if app.right_pane != RightPane::Empty {
Borders::RIGHT
} else {
Borders::NONE
})
.border_style(border_style),
);
f.render_widget(sidebar, area);
}
fn draw_right_pane(f: &mut Frame, app: &App, area: Rect) {
match app.right_pane {
RightPane::AgentConversation(id) => draw_conversation(f, app, id, area),
RightPane::ShellTerminal(id) => draw_terminal(f, app, id, area),
RightPane::Empty => {}
}
}
fn draw_conversation(f: &mut Frame, app: &App, shell_id: uuid::Uuid, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
let block = Block::default()
.title(format!(
" agent {} — p:prompt s:shell Esc:close ",
&shell_id.to_string()[..8]
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(chunks[0]);
f.render_widget(block, chunks[0]);
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);
let prompt_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let prompt_inner = prompt_block.inner(chunks[1]);
f.render_widget(prompt_block, chunks[1]);
let hint = Paragraph::new(Line::from(Span::styled(
" press p to send a prompt",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(hint, prompt_inner);
}
fn draw_terminal(f: &mut Frame, app: &App, shell_id: uuid::Uuid, area: Rect) {
let block = Block::default()
.title(format!(
" shell {} — Ctrl+] to close ",
&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_dialog(f: &mut Frame, app: &App) {
match &app.dialog {
Dialog::SpawnAgent { repo_idx } => {
let height = (app.state.repos.len() + 3).min(15) as u16;
let area = centered_rect(50, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" Spawn Agent — pick repo (Enter to confirm) ")
.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 == *repo_idx {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(format!(" {}", r.name), style)))
})
.collect();
f.render_widget(List::new(items), inner);
}
Dialog::PromptAgent { shell_id } => {
let area = centered_rect(60, 5, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(format!(
" Prompt agent {} (Enter to send) ",
&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);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(Color::White),
))),
inner,
);
}
Dialog::NewRepo {
path_buf,
name_buf,
editing_name,
} => {
let area = centered_rect(60, 7, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" Add Repo (Tab to switch field, Enter to add) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let path_style = if !*editing_name {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let name_style = if *editing_name {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::White)
};
let lines = vec![
Line::from(Span::styled(
format!(
"Path: {}{}",
path_buf,
if !*editing_name { "_" } else { "" }
),
path_style,
)),
Line::from(Span::styled(
format!("Name: {}{}", name_buf, if *editing_name { "_" } else { "" }),
name_style,
)),
];
f.render_widget(Paragraph::new(lines), inner);
}
Dialog::NewWorkstream { repo_idx, name_buf } => {
let height = (app.state.repos.len() + 5).min(15) as u16;
let area = centered_rect(50, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.title(" New Workstream (j/k: repo, type name, Enter) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
format!("Name: {}_", name_buf),
Style::default().fg(Color::Yellow),
))];
lines.push(Line::from(Span::styled(
"Repo:",
Style::default().fg(Color::DarkGray),
)));
for (i, r) in app.state.repos.iter().enumerate() {
let style = if i == *repo_idx {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(Span::styled(format!(" {}", r.name), style)));
}
f.render_widget(Paragraph::new(lines), inner);
}
Dialog::None => {}
}
}
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))
}