use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use crate::app::App;
use crate::data::{state_glyph, tree_row_meta, AgentInfo, TreeRowMeta};
use crate::mailbox::{render_row, MailboxInputKind, MailboxTab};
use crate::theme::ColorMode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MainLayout {
Triptych,
Wall,
MailboxFirst,
}
impl MainLayout {
pub fn toggle_wall(self) -> Self {
if matches!(self, MainLayout::Wall) {
MainLayout::Triptych
} else {
MainLayout::Wall
}
}
pub fn toggle_mailbox_first(self) -> Self {
if matches!(self, MainLayout::MailboxFirst) {
MainLayout::Triptych
} else {
MainLayout::MailboxFirst
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Roster,
Detail,
Mailbox,
}
impl Pane {
pub fn next(self) -> Self {
match self {
Pane::Roster => Pane::Detail,
Pane::Detail => Pane::Mailbox,
Pane::Mailbox => Pane::Roster,
}
}
pub fn prev(self) -> Self {
match self {
Pane::Roster => Pane::Mailbox,
Pane::Detail => Pane::Roster,
Pane::Mailbox => Pane::Detail,
}
}
}
pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
Triptych { app }.render(area, f.buffer_mut());
}
pub struct Triptych<'a> {
pub app: &'a App,
}
impl Widget for Triptych<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let stripe_visible = self.app.has_pending_approvals();
let body = if stripe_visible {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
render_approvals_stripe(buf, v[0], self.app);
v[1]
} else {
area
};
let outer = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(28), Constraint::Min(0), ])
.split(body);
let right_stack = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)])
.split(outer[1]);
render_agents(buf, outer[0], self.app);
render_detail(buf, right_stack[0], self.app);
render_mailbox(buf, right_stack[1], self.app);
}
}
fn render_approvals_stripe(buf: &mut Buffer, area: Rect, app: &App) {
let n = app.pending_approvals.len();
let plural = if n == 1 { "" } else { "s" };
let text = format!("⚠ approvals: {n} pending{plural} — `a` to review");
let style = Style::default()
.fg(app.capabilities.accent())
.add_modifier(Modifier::REVERSED | Modifier::BOLD);
Paragraph::new(text)
.style(style)
.alignment(Alignment::Left)
.render(area, buf);
}
fn render_agents(buf: &mut Buffer, area: Rect, app: &App) {
let focused = app.focused_pane == Pane::Roster;
let block = pane_block("AGENTS", focused, app);
let inner = block.inner(area);
block.render(area, buf);
if app.team.agents.is_empty() {
let empty = Paragraph::new("(no agents)")
.style(Style::default().fg(app.capabilities.muted()))
.alignment(Alignment::Center);
empty.render(inner, buf);
return;
}
let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
let metas = tree_row_meta(&app.team.agents);
let lines: Vec<Line<'_>> = app
.team
.agents
.iter()
.zip(metas.iter())
.enumerate()
.map(|(i, (info, meta))| agent_line(info, *meta, Some(i) == app.selected_agent, ascii, app))
.collect();
let para = Paragraph::new(lines).alignment(Alignment::Left);
para.render(inner, buf);
}
fn tree_prefix(meta: TreeRowMeta, ascii: bool) -> String {
let branch = match (meta.is_last_sibling, ascii) {
(false, false) => "├─",
(true, false) => "└─",
(false, true) => "|-",
(true, true) => "`-",
};
match meta.depth {
0 => " ".to_string(),
1 => format!(" {branch} "),
_ => format!(" {branch} "),
}
}
fn agent_line<'a>(
info: &'a AgentInfo,
meta: TreeRowMeta,
selected: bool,
ascii: bool,
app: &App,
) -> Line<'a> {
let glyph = state_glyph(info, ascii, app.now_secs);
let label = info.display_name.as_deref().unwrap_or(&info.agent);
let prefix = tree_prefix(meta, ascii);
let display = format!("{prefix}{glyph} {label}");
let style = if selected {
Style::default()
.fg(app.capabilities.accent())
.add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
Line::styled(display, style)
}
fn render_detail(buf: &mut Buffer, area: Rect, app: &App) {
let focused_pane = app.focused_pane == Pane::Detail;
let stream = matches!(app.stage, crate::app::Stage::StreamKeys);
let title = match app
.selected_agent
.and_then(|i| app.team.agents.get(i))
.map(|a| crate::data::agent_label(&app.team, &a.id))
{
Some(label) if stream => format!("DETAIL · {label} [STREAM-KEYS]"),
Some(label) => format!("DETAIL · {label}"),
None if stream => "DETAIL [STREAM-KEYS]".to_string(),
None => "DETAIL".to_string(),
};
let outer_block = pane_block(&title, focused_pane || stream, app);
let inner = outer_block.inner(area);
outer_block.render(area, buf);
if app.selected_agent.is_none() || app.team.agents.is_empty() {
let muted = Style::default().fg(app.capabilities.muted());
Paragraph::new("(select an agent on the left to follow its session)")
.style(muted)
.alignment(Alignment::Center)
.render(inner, buf);
return;
}
if !app.detail_splits.is_empty() {
render_detail_splits(buf, inner, app);
return;
}
if app.detail_buffer.is_empty() {
let muted = Style::default().fg(app.capabilities.muted());
Paragraph::new("(no scrollback yet — agent may be starting up)")
.style(muted)
.alignment(Alignment::Center)
.render(inner, buf);
return;
}
let cap = inner.height as usize;
let start = app.detail_buffer.len().saturating_sub(cap);
use ansi_to_tui::IntoText;
let lines: Vec<Line<'_>> = app.detail_buffer[start..]
.iter()
.flat_map(|s| match s.as_bytes().into_text() {
Ok(text) => text.lines.into_iter().collect::<Vec<_>>(),
Err(_) => vec![Line::raw(s.clone())],
})
.collect();
Paragraph::new(lines).render(inner, buf);
}
fn render_detail_splits(buf: &mut Buffer, area: Rect, app: &App) {
use ratatui::layout::Direction as Dir;
let focused_id = app
.selected_agent_id()
.unwrap_or_else(|| "<no agent>".into());
let mut cells: Vec<(String, crate::app::SplitOrientation, bool)> = Vec::new();
cells.push((
focused_id,
app.detail_splits
.first()
.map(|(_, o)| *o)
.unwrap_or(crate::app::SplitOrientation::Vertical),
app.selected_split == 0 && app.focused_pane == Pane::Detail,
));
for (i, (id, orientation)) in app.detail_splits.iter().enumerate() {
cells.push((
id.clone(),
*orientation,
app.selected_split == i + 1 && app.focused_pane == Pane::Detail,
));
}
let mut columns: Vec<Vec<usize>> = vec![vec![0]];
for (idx, (_, orientation, _)) in cells.iter().enumerate().skip(1) {
match orientation {
crate::app::SplitOrientation::Vertical => columns.push(vec![idx]),
crate::app::SplitOrientation::Horizontal => {
columns.last_mut().expect("seed column").push(idx);
}
}
}
let col_count = columns.len();
let col_constraints: Vec<Constraint> = (0..col_count)
.map(|_| Constraint::Ratio(1, col_count as u32))
.collect();
let col_areas = ratatui::layout::Layout::default()
.direction(Dir::Horizontal)
.constraints(col_constraints)
.split(area);
for (col_idx, col_cells) in columns.iter().enumerate() {
let col_area = col_areas[col_idx];
let row_count = col_cells.len();
let row_constraints: Vec<Constraint> = (0..row_count)
.map(|_| Constraint::Ratio(1, row_count as u32))
.collect();
let row_areas = ratatui::layout::Layout::default()
.direction(Dir::Vertical)
.constraints(row_constraints)
.split(col_area);
for (row_idx, &cell_idx) in col_cells.iter().enumerate() {
let cell_area = row_areas[row_idx];
let (agent_id, _, is_focused_split) = &cells[cell_idx];
render_split_cell(buf, cell_area, app, agent_id, *is_focused_split);
}
}
}
fn render_split_cell(
buf: &mut Buffer,
area: Rect,
app: &App,
agent_id: &str,
is_focused_split: bool,
) {
let ascii = matches!(app.capabilities.color, ColorMode::Monochrome);
let glyph = app
.team
.agents
.iter()
.find(|a| a.id == agent_id)
.map(|info| crate::data::state_glyph(info, ascii, app.now_secs))
.unwrap_or("?");
let label = crate::data::agent_label(&app.team, agent_id);
let title = format!(" {glyph} {label} ");
let border = if is_focused_split {
Style::default()
.fg(app.capabilities.accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.capabilities.muted())
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border);
let inner = block.inner(area);
block.render(area, buf);
let muted = Style::default().fg(app.capabilities.muted());
if !is_focused_split {
Paragraph::new("(focus this split to stream)")
.style(muted)
.alignment(Alignment::Center)
.render(inner, buf);
return;
}
if app.detail_buffer.is_empty() {
Paragraph::new("(no scrollback yet)")
.style(muted)
.alignment(Alignment::Center)
.render(inner, buf);
return;
}
let cap = inner.height as usize;
let start = app.detail_buffer.len().saturating_sub(cap);
let lines: Vec<Line<'_>> = app.detail_buffer[start..]
.iter()
.map(|s| Line::raw(s.clone()))
.collect();
Paragraph::new(lines).render(inner, buf);
}
fn render_mailbox(buf: &mut Buffer, area: Rect, app: &App) {
let focused = app.focused_pane == Pane::Mailbox;
let block = pane_block("MAILBOX", focused, app);
let inner = block.inner(area);
block.render(area, buf);
if inner.height == 0 {
return;
}
let tab = app.mailbox_tab;
let input_open = app.mailbox_input_mode.is_some();
let filter = app.mailbox.filter_text(tab);
let search = app.mailbox.search_text(tab);
let indicator_visible = !input_open && (!filter.is_empty() || !search.is_empty());
let aux_height = if input_open || indicator_visible {
1
} else {
0
};
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(aux_height), Constraint::Min(0), ])
.split(inner);
render_mailbox_tabs(buf, layout[0], app);
if aux_height == 1 {
render_mailbox_aux(buf, layout[1], app);
}
render_mailbox_body(buf, layout[2], app);
}
fn render_mailbox_aux(buf: &mut Buffer, area: Rect, app: &App) {
let tab = app.mailbox_tab;
let muted = Style::default().fg(app.capabilities.muted());
let text = match app.mailbox_input_mode {
Some(MailboxInputKind::Filter) => {
format!("filter: {}\u{2588}", app.mailbox.filter_text(tab))
}
Some(MailboxInputKind::Search) => {
format!("search: {}\u{2588}", app.mailbox.search_text(tab))
}
None => {
let filter = app.mailbox.filter_text(tab);
let search = app.mailbox.search_text(tab);
match (filter.is_empty(), search.is_empty()) {
(false, false) => format!("filter: {filter} search: {search}"),
(false, true) => format!("filter: {filter}"),
(true, false) => format!("search: {search}"),
(true, true) => String::new(), }
}
};
Paragraph::new(text).style(muted).render(area, buf);
}
fn render_mailbox_tabs(buf: &mut Buffer, area: Rect, app: &App) {
let active_style = Style::default()
.fg(app.capabilities.accent())
.add_modifier(Modifier::REVERSED);
let muted = Style::default().fg(app.capabilities.muted());
let mut spans: Vec<ratatui::text::Span<'_>> = Vec::with_capacity(7);
for (i, tab) in MailboxTab::ALL.iter().enumerate() {
if i > 0 {
spans.push(ratatui::text::Span::styled(" ", muted));
}
let label = format!(" {} ", tab.label());
let style = if app.mailbox_tab == *tab {
active_style
} else {
muted
};
spans.push(ratatui::text::Span::styled(label, style));
}
Paragraph::new(Line::from(spans)).render(area, buf);
}
fn render_mailbox_body(buf: &mut Buffer, area: Rect, app: &App) {
if app.selected_agent_id().is_none() {
let muted = Style::default().fg(app.capabilities.muted());
Paragraph::new("(select an agent)")
.style(muted)
.alignment(Alignment::Center)
.render(area, buf);
return;
}
let rows = app.mailbox.rows(app.mailbox_tab);
if rows.is_empty() {
let muted = Style::default().fg(app.capabilities.muted());
Paragraph::new(app.mailbox_tab.empty_hint())
.style(muted)
.alignment(Alignment::Center)
.render(area, buf);
return;
}
let visible = app.mailbox.visible_indices(app.mailbox_tab);
if visible.is_empty() {
return;
}
let cap = area.height as usize;
let selected = app
.mailbox
.cursor(app.mailbox_tab)
.selected_idx
.min(visible.len() - 1);
let start = if visible.len() <= cap {
0
} else if visible.len() - selected <= cap {
visible.len() - cap
} else {
selected.saturating_sub(cap.saturating_sub(1))
};
let end = (start + cap).min(visible.len());
let focused = app.focused_pane == Pane::Mailbox;
let highlight = Style::default().add_modifier(Modifier::REVERSED);
let muted = Style::default().fg(app.capabilities.muted());
let now_secs = app.now_secs;
const TIME_INDICATOR_WIDTH: usize = 12;
const TIME_INDICATOR_GUTTER: usize = 1;
let row_width = area.width as usize;
let lines: Vec<Line<'_>> = visible[start..end]
.iter()
.map(|&row_idx| {
let row = &rows[row_idx];
let left = render_row(row, &app.team, app.mailbox_tab);
let rtime = crate::mailbox::row_timestamp(now_secs, row.sent_at);
let reserved = TIME_INDICATOR_WIDTH + TIME_INDICATOR_GUTTER;
let left_chars = left.chars().count();
let max_left = row_width.saturating_sub(reserved);
let left_trimmed = if left_chars > max_left {
left.chars().take(max_left).collect::<String>()
} else {
left
};
let pad_n = max_left.saturating_sub(left_trimmed.chars().count());
let pad = " ".repeat(pad_n);
let indicator = format!("{rtime:>width$}", width = TIME_INDICATOR_WIDTH);
let line = Line::from(vec![
ratatui::text::Span::raw(left_trimmed),
ratatui::text::Span::raw(pad),
ratatui::text::Span::raw(" ".repeat(TIME_INDICATOR_GUTTER)),
ratatui::text::Span::styled(indicator, muted),
]);
if focused && row_idx == visible[selected] {
line.style(highlight)
} else {
line
}
})
.collect();
Paragraph::new(lines).render(area, buf);
}
fn pane_block<'a>(title: &'a str, focused: bool, app: &App) -> Block<'a> {
let border = if focused {
Style::default()
.fg(app.capabilities.accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.capabilities.muted())
};
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border)
}