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 serde_json::Value;
use crate::app::{App, Dialog, RightPane, SidebarEntry, repo_display_name};
use vex_proto::{AgentId, AgentStatus, WorkstreamId};
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 mut spans = vec![
Span::styled(
format!(" vex v{}", version),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
spans.push(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(Line::from(spans)), 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 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, .. } => {
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
let prefix = if is_cursor { " > " } else { " " };
items.push(ListItem::new(Line::from(Span::styled(
format!("{}{}", prefix, name),
style,
))));
}
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::RepoDetail(path) => draw_repo_detail(f, app, path, area),
RightPane::WorkstreamDetail(id, name) => draw_workstream_detail(f, app, *id, name, area),
RightPane::Empty => {}
}
}
fn extract_user_text(parsed: &Value) -> Option<String> {
let content = parsed.get("message")?.get("content")?;
let text = if let Some(s) = content.as_str() {
s.to_string()
} else if let Some(arr) = content.as_array() {
arr.iter()
.filter(|c| c.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|c| c.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n")
} else {
return None;
};
Some(text.trim().to_string())
}
fn extract_assistant_text(parsed: &Value) -> Option<String> {
let content = parsed.get("message")?.get("content")?;
let text = if let Some(s) = content.as_str() {
s.to_string()
} else if let Some(arr) = content.as_array() {
arr.iter()
.filter_map(|c| {
let typ = c.get("type").and_then(|t| t.as_str())?;
match typ {
"text" => c.get("text").and_then(|t| t.as_str()).map(String::from),
"tool_use" => {
let name = c.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
let path = c
.get("input")
.and_then(|i| {
i.get("file_path")
.or_else(|| i.get("path"))
.or_else(|| i.get("command"))
})
.and_then(|v| v.as_str())
.unwrap_or("");
Some(format!("[tool: {}] {}", name, path))
}
_ => None,
}
})
.collect::<Vec<_>>()
.join("\n")
} else {
return None;
};
Some(text.trim().to_string())
}
fn draw_conversation(f: &mut Frame, app: &App, agent_id: AgentId, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
let mode_label = if app.show_raw { "[v]parsed" } else { "[v]raw" };
let agent_name = app
.state
.agents
.iter()
.find(|a| a.id == agent_id)
.and_then(|a| a.title.clone());
let title_id = agent_name.unwrap_or_else(|| format!("agent-{}", agent_id));
let block = Block::default()
.title(format!(
" agent {} — {} p:prompt s:shell Esc:close ",
title_id, mode_label,
))
.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(&agent_id)
.map(|raw_lines| {
if app.show_raw {
raw_lines
.iter()
.map(|l| Line::from(Span::raw(l.clone())))
.collect()
} else {
raw_lines
.iter()
.filter_map(|l| {
let parsed: Value = serde_json::from_str(l).ok()?;
let msg_type = parsed.get("type")?.as_str()?;
match msg_type {
"user" => {
let text = extract_user_text(&parsed).unwrap_or_default();
if text.is_empty() {
return None;
}
Some(
text.lines()
.map(|line| {
Line::from(Span::styled(
format!("> {}", line),
Style::default().fg(Color::Blue),
))
})
.collect::<Vec<_>>(),
)
}
"assistant" => {
let text = extract_assistant_text(&parsed).unwrap_or_default();
if text.is_empty() {
return None;
}
Some(
text.lines()
.map(|line| Line::from(Span::raw(line.to_string())))
.collect::<Vec<_>>(),
)
}
_ => None,
}
})
.flatten()
.collect()
}
})
.unwrap_or_default();
let total = lines.len();
let visible = inner.height as usize;
let max_scroll = total.saturating_sub(visible);
let scroll_from_bottom = app.conversation_scroll.min(max_scroll);
let skip = max_scroll.saturating_sub(scroll_from_bottom);
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: vex_proto::ShellId, area: Rect) {
let block = Block::default()
.title(format!(" shell {} — Ctrl+] to close ", shell_id))
.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_repo_detail(f: &mut Frame, app: &App, repo_path: &str, area: Rect) {
let display_name = repo_display_name(repo_path);
let block = Block::default()
.title(format!(
" Repo: {} — c:agent s:shell Esc:close ",
display_name
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if let Some(repo) = app.state.repos.iter().find(|r| r.path == repo_path) {
lines.push(Line::from(Span::styled(
format!(" Path: {}", repo.path),
Style::default().fg(Color::Cyan),
)));
if let Some(ref branch) = repo.current_branch {
lines.push(Line::from(Span::styled(
format!(" Branch: {}", branch),
Style::default().fg(Color::White),
)));
}
if let Some(ref remote) = repo.remote_url {
lines.push(Line::from(Span::styled(
format!(" Remote: {}", remote),
Style::default().fg(Color::White),
)));
}
if repo.is_dirty {
lines.push(Line::from(Span::styled(
" Status: dirty",
Style::default().fg(Color::Yellow),
)));
}
}
lines.push(Line::from(Span::raw("")));
let workstreams: Vec<_> = app
.state
.workstreams
.iter()
.filter(|ws| ws.repo_path.as_deref() == Some(repo_path))
.collect();
lines.push(Line::from(Span::styled(
format!(" Workstreams ({})", workstreams.len()),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
if workstreams.is_empty() {
lines.push(Line::from(Span::styled(
" (none — press w to create)",
Style::default().fg(Color::DarkGray),
)));
} else {
for ws in &workstreams {
let branch_info = ws
.branch
.as_deref()
.map(|b| format!(" ({})", b))
.unwrap_or_default();
lines.push(Line::from(Span::styled(
format!(" {}{}", ws.name, branch_info),
Style::default().fg(Color::White),
)));
}
}
lines.push(Line::from(Span::raw("")));
let ws_ids: Vec<_> = workstreams.iter().map(|ws| ws.id).collect();
let agents: Vec<_> = app
.state
.agents
.iter()
.filter(|a| a.workstream_id.is_some_and(|wid| ws_ids.contains(&wid)))
.collect();
lines.push(Line::from(Span::styled(
format!(" Agents ({})", agents.len()),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)));
if agents.is_empty() {
lines.push(Line::from(Span::styled(
" (none)",
Style::default().fg(Color::DarkGray),
)));
} else {
for a in &agents {
let title = a.title.as_deref().unwrap_or("unknown");
let status = if a.status == AgentStatus::Waiting {
"NEEDS"
} else {
"idle"
};
lines.push(Line::from(Span::styled(
format!(" {} — {}", title, status),
Style::default().fg(Color::White),
)));
}
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
fn draw_workstream_detail(
f: &mut Frame,
app: &App,
ws_id: WorkstreamId,
ws_name: &str,
area: Rect,
) {
let block = Block::default()
.title(format!(
" Workstream: {} — c:agent s:shell x:delete Esc:close ",
ws_name
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if let Some(ws) = app.state.workstreams.iter().find(|w| w.id == ws_id) {
lines.push(Line::from(Span::styled(
format!(" Name: {}", ws.name),
Style::default().fg(Color::Cyan),
)));
if let Some(ref rp) = ws.repo_path {
lines.push(Line::from(Span::styled(
format!(" Repo: {}", rp),
Style::default().fg(Color::White),
)));
}
if let Some(ref branch) = ws.branch {
lines.push(Line::from(Span::styled(
format!(" Branch: {}", branch),
Style::default().fg(Color::White),
)));
}
lines.push(Line::from(Span::styled(
format!(" Shells: {}", ws.shell_count),
Style::default().fg(Color::White),
)));
lines.push(Line::from(Span::styled(
format!(" Agents: {}", ws.agent_count),
Style::default().fg(Color::White),
)));
} else {
lines.push(Line::from(Span::styled(
" (workstream not found)",
Style::default().fg(Color::DarkGray),
)));
}
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(para, 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!(" {}", repo_display_name(&r.path)),
style,
)))
})
.collect();
f.render_widget(List::new(items), inner);
}
Dialog::PromptAgent { agent_id } => {
let line_count = app.prompt_buf.lines().count().max(1);
let height = (line_count as u16 + 4).min(f.area().height.saturating_sub(4));
let area = centered_rect(60, height, f.area());
f.render_widget(Clear, area);
let agent_name = app
.state
.agents
.iter()
.find(|a| a.id == *agent_id)
.and_then(|a| a.title.clone());
let title_id = agent_name.unwrap_or_else(|| format!("agent-{}", agent_id));
let block = Block::default()
.title(format!(" Prompt {} (Alt+Enter to send) ", title_id))
.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> = app
.prompt_buf
.split('\n')
.map(|l| {
Line::from(Span::styled(
format!("> {}", l),
Style::default().fg(Color::White),
))
})
.collect();
if let Some(last) = lines.last_mut() {
let mut text = last.spans[0].content.to_string();
text.push('_');
*last = Line::from(Span::styled(text, Style::default().fg(Color::White)));
}
lines.push(Line::from(Span::styled(
"Enter:newline Alt+Enter:send Esc:cancel",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), 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!(" {}", repo_display_name(&r.path)),
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))
}