vex-tui 0.6.0

Vex TUI dashboard — ratatui-based terminal interface
Documentation
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), // Header
            Constraint::Min(0),    // Body
            Constraint::Length(1), // Footer
        ])
        .split(f.area());

    // Header
    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]);

    // Body: sidebar + main
    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]);

    // Footer
    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 &sections {
        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);

    // Agents table
    draw_agents_table(f, app, chunks[0]);
    // Shells table
    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 the dashboard behind
    draw_dashboard(f, app);

    // Popup overlay
    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 the dashboard behind
    draw_dashboard(f, app);

    // Popup overlay
    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))
}