use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as TuiLine, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap};
use crossterm::event::KeyCode;
use crate::tui::state::AppState;
use crate::tui::state::diff::{DiffLine, Line, diff_lines};
use crate::tui::state::layout::{ClickTarget, ClickZone, Split};
use crate::tui::state::overlay::{LEADER_LEAVES, Mode, PickerPurpose, PickerState};
use crate::tui::state::pane::{PaneState, PendingView, TailKind};
const RAIL_WIDTH: u16 = 28;
const SESSIONS_WIDTH: u16 = 24;
const CENTER_MIN_WIDTH: u16 = 48;
pub struct PtyView {
pub record_id: String,
pub lines: Vec<TuiLine<'static>>,
pub scrolled: bool,
}
pub fn render(state: &mut AppState, pty: &[PtyView], frame: &mut Frame) {
let area = frame.area();
let rail_w = RAIL_WIDTH.min(area.width / 3);
let sessions_allowed = !state.sessions_collapsed;
let rail_allowed = !state.subagents_collapsed;
let sessions_content = !state.sessions_list().is_empty();
let rail_content = state
.agents
.iter()
.any(|p| p.kind == crate::tui::state::pane::PaneKind::Monitor);
let fits_both = area.width >= SESSIONS_WIDTH + rail_w + CENTER_MIN_WIDTH;
let (show_sessions, show_rail) = if fits_both {
(sessions_allowed, rail_allowed)
} else {
let prefer_sessions = sessions_allowed && sessions_content && !rail_content;
if prefer_sessions && area.width >= SESSIONS_WIDTH + CENTER_MIN_WIDTH {
(true, false)
} else if rail_allowed && area.width >= rail_w + CENTER_MIN_WIDTH {
(false, true)
} else if sessions_allowed && area.width >= SESSIONS_WIDTH + CENTER_MIN_WIDTH {
(true, false)
} else {
(false, false)
}
};
let mut constraints = Vec::new();
if show_sessions {
constraints.push(Constraint::Length(SESSIONS_WIDTH));
}
constraints.push(Constraint::Min(1));
if show_rail {
constraints.push(Constraint::Length(rail_w));
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(area);
let center = cols[if show_sessions { 1 } else { 0 }];
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(center);
let mut zones: Vec<ClickZone> = Vec::new();
if show_sessions {
render_sessions(state, &mut zones, frame, cols[0]);
}
render_detail(state, pty, frame, rows[0]);
if show_rail {
render_rail(state, &mut zones, frame, cols[cols.len() - 1]);
}
render_statusbar(state, &mut zones, frame, rows[rows.len() - 1]);
state.click_zones = zones;
if state.mode == Mode::Picker
&& let Some(picker) = &state.picker
{
render_picker(picker, frame, area);
}
if state.mode == Mode::Command
&& let Some(palette) = &state.palette
{
render_palette(palette, state.no_color, frame, area);
}
if state.mode == Mode::Confirm {
render_confirm(state, frame, area);
}
if state.keys_help || state.mode == Mode::Leader {
render_keys_help(state.mode, &state.leader_label(), frame, area);
}
if let Some(pane) = state.focused()
&& let Some(pending) = &pane.pending
{
render_permission(pending, state.no_color, frame, area);
}
}
fn render_palette(
palette: &crate::tui::state::overlay::PaletteState,
nc: bool,
frame: &mut Frame,
area: Rect,
) {
let popup = centered(area, 50, 50);
frame.render_widget(Clear, popup);
let mut lines: Vec<TuiLine> = vec![TuiLine::from(vec![
Span::raw(": "),
Span::raw(palette.input.clone()),
Span::styled("▏", Style::default().add_modifier(Modifier::BOLD)),
])];
let matches = palette.matches();
if matches.is_empty() {
lines.push(TuiLine::styled(
"(no matching command)",
tint(nc, Color::DarkGray),
));
}
for (i, (name, _)) in matches.iter().enumerate() {
if i == palette.selected.min(matches.len() - 1) {
lines.push(TuiLine::styled(
format!("> {name}"),
Style::default().add_modifier(Modifier::BOLD),
));
} else {
lines.push(TuiLine::raw(format!(" {name}")));
}
}
let para =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" command "));
frame.render_widget(para, popup);
}
fn render_confirm(state: &AppState, frame: &mut Frame, area: Rect) {
let nc = state.no_color;
let popup = centered(area, 70, 40);
frame.render_widget(Clear, popup);
let cmd = state.bootstrap_cmd.as_deref().unwrap_or_default();
let agent = state.confirm_agent.as_deref().unwrap_or_default();
let lines: Vec<TuiLine> = vec![
TuiLine::raw(format!(
"spawning {agent} into an isolated worktree — run the configured"
)),
TuiLine::raw("bootstrap hook in each new worktree? It executes shell:"),
TuiLine::raw(""),
TuiLine::styled(
format!(" {cmd}"),
Style::default().add_modifier(Modifier::BOLD),
),
TuiLine::raw(""),
TuiLine::from(vec![
Span::styled("[y]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" run for this session "),
Span::styled("[n]", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" skip this session "),
Span::styled("[Esc]", tint(nc, Color::DarkGray)),
Span::raw(" cancel spawn"),
]),
];
let para = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(" worktree bootstrap "),
)
.wrap(Wrap { trim: false });
frame.render_widget(para, popup);
}
fn render_keys_help(mode: Mode, leader: &str, frame: &mut Frame, area: Rect) {
let static_rows = |rows: &[(&str, &str)]| -> Vec<(String, String)> {
rows.iter()
.map(|&(k, w)| (k.to_string(), w.to_string()))
.collect()
};
let bindings: Vec<(String, String)> = match mode {
Mode::Normal | Mode::Command => static_rows(&[
(
"y / a / n",
"resolve the top pending decision (batch-clears)",
),
("D / m / p / r", "review: diff · merge · apply · reject"),
("PgUp / PgDn", "scroll the focused scrollback"),
(":", "command palette"),
(leader, "leader (one-shot menu)"),
("Ctrl-C", "interrupt the focused agent"),
]),
Mode::Leader => {
let mut rows = vec![("1-9".to_string(), "focus session N".to_string())];
rows.extend(LEADER_LEAVES.iter().map(|(key, what, _)| {
let label = match key {
KeyCode::Tab => "Tab".to_string(),
KeyCode::Char(c) => c.to_string(),
other => format!("{other:?}"),
};
(label, (*what).to_string())
}));
rows.push(("Esc".to_string(), "cancel".to_string()));
rows
}
Mode::Picker => static_rows(&[("↑ / ↓", "select"), ("Enter", "spawn"), ("Esc", "cancel")]),
Mode::Confirm => static_rows(&[
("y", "run bootstrap this session"),
("n", "skip bootstrap this session"),
("Esc", "cancel the spawn"),
]),
};
let popup = centered(area, 60, 60);
frame.render_widget(Clear, popup);
let lines: Vec<TuiLine> = bindings
.into_iter()
.map(|(key, what)| {
TuiLine::from(vec![
Span::styled(
format!("{key:>18} "),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(what),
])
})
.collect();
let para = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(" keys "));
frame.render_widget(para, popup);
}
const SPINNER: [&str; 8] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
fn tint(no_color: bool, color: Color) -> Style {
if no_color {
Style::default()
} else {
Style::default().fg(color)
}
}
fn state_glyph(pane: &PaneState, tick: u64) -> &'static str {
if pane.pending.is_some() {
"⚠" } else if pane.review.is_some() && !pane.exited {
"◆" } else if pane.attention {
"●" } else if pane.done && !pane.exited {
"◉" } else if !pane.exited && pane.turn_active {
SPINNER[(tick % 8) as usize] } else if !pane.exited {
"○" } else {
"✗" }
}
fn state_word(pane: &PaneState) -> &'static str {
if pane.pending.is_some() {
"needs you"
} else if pane.review.is_some() && !pane.exited {
"review"
} else if pane.attention {
"attention"
} else if pane.done && !pane.exited {
"done"
} else if !pane.exited && pane.turn_active {
"working"
} else if !pane.exited {
"idle"
} else {
"exited"
}
}
fn header_line(text: String, nc: bool) -> TuiLine<'static> {
TuiLine::styled(text, tint(nc, Color::DarkGray).add_modifier(Modifier::BOLD))
}
fn render_sessions(state: &AppState, zones: &mut Vec<ClickZone>, frame: &mut Frame, area: Rect) {
let nc = state.no_color;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let order = state.sessions_list();
let focused_id = state.focused().map(|p| p.record_id.clone());
let mut cursor_end = 0usize;
let mut row_spans: Vec<(usize, usize)> = Vec::new();
let mut lines: Vec<TuiLine> = vec![header_line("sessions".to_string(), nc)];
for &idx in order.iter() {
let pane = &state.agents[idx];
let glyph = state_glyph(pane, state.tick);
let at_cursor = focused_id.as_deref() == Some(pane.record_id.as_str());
let cursor = if at_cursor { "▸" } else { " " };
let shown = state.detail.shown.iter().any(|r| r == &pane.record_id);
let mut name_style = Style::default();
if shown {
name_style = name_style.add_modifier(Modifier::BOLD);
}
lines.push(TuiLine::raw(""));
let start = lines.len();
lines.push(TuiLine::from(vec![
Span::raw(cursor.to_string()),
Span::raw(glyph.to_string()),
Span::raw(" "),
Span::styled(pane.agent_id.clone(), name_style),
]));
let meta = if pane.harness == "attach" {
"attach".to_string()
} else {
pane.model.as_deref().unwrap_or("default model").to_string()
};
lines.push(TuiLine::from(vec![
Span::raw(" "),
Span::styled(meta, tint(nc, Color::DarkGray)),
]));
row_spans.push((start, lines.len()));
if at_cursor {
cursor_end = lines.len();
}
}
if order.is_empty() {
lines.push(TuiLine::raw(""));
lines.push(TuiLine::styled("(no sessions)", tint(nc, Color::DarkGray)));
}
let scroll = scroll_to_cursor(cursor_end, chunks[0].height);
let para = Paragraph::new(lines)
.block(Block::default().borders(Borders::RIGHT))
.scroll((scroll, 0));
frame.render_widget(para, chunks[0]);
for (row, (lo, hi)) in row_spans.into_iter().enumerate() {
if let Some((y, h)) = screen_span(chunks[0], scroll as usize, lo, hi) {
zones.push(ClickZone {
x: chunks[0].x,
y,
w: chunks[0].width,
h,
target: ClickTarget::SessionRow(row),
});
}
}
let footer = Paragraph::new(TuiLine::styled(
format!("+ new session ({} n)", state.leader_label()),
tint(nc, Color::DarkGray),
))
.block(Block::default().borders(Borders::RIGHT));
zones.push(ClickZone {
x: chunks[1].x,
y: chunks[1].y,
w: chunks[1].width,
h: chunks[1].height,
target: ClickTarget::NewSession,
});
frame.render_widget(footer, chunks[1]);
}
fn render_rail(state: &AppState, zones: &mut Vec<ClickZone>, frame: &mut Frame, area: Rect) {
let nc = state.no_color;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(area);
let order = state.roster();
let focused_id = state.focused().map(|p| p.record_id.clone());
let top_pending_id = order
.iter()
.find(|&&i| state.agents[i].pending.is_some())
.map(|&i| state.agents[i].record_id.clone());
let header = "subagents".to_string();
let mut cursor_end = 0usize;
let mut row_spans: Vec<(usize, usize)> = Vec::new();
let mut lines: Vec<TuiLine> = vec![header_line(header, nc)];
for &idx in order.iter() {
let pane = &state.agents[idx];
let glyph = state_glyph(pane, state.tick);
let at_cursor = focused_id.as_deref() == Some(pane.record_id.as_str());
let cursor = if at_cursor { "▸" } else { " " };
let shown = state.detail.shown.iter().any(|r| r == &pane.record_id);
let mut name_style = Style::default();
if shown {
name_style = name_style.add_modifier(Modifier::BOLD);
}
let spans = vec![
Span::raw(cursor.to_string()),
Span::raw(glyph.to_string()),
Span::raw(" "),
Span::styled(pane.agent_id.clone(), name_style),
];
lines.push(TuiLine::raw(""));
let start = lines.len();
lines.push(TuiLine::from(spans));
let mut meta = vec![Span::raw(" ")];
let mut words = vec![state_word(pane).to_string()];
if !pane.harness.is_empty() {
words.push(pane.harness.clone());
}
if let Some(elapsed) = pane.elapsed_label(state.tick) {
words.push(elapsed);
}
if let Some(port) = pane.port {
words.push(format!(":{port}"));
}
if let Some(cost) = &pane.cost {
words.push(fmt_cost(cost).trim_start().to_string());
}
match pane.autonomy {
crate::tui::state::pane::Autonomy::Manual => {}
crate::tui::state::pane::Autonomy::Assisted => words.push("[a]".to_string()),
crate::tui::state::pane::Autonomy::Auto => words.push("[A]".to_string()),
}
meta.push(Span::styled(words.join(" · "), tint(nc, Color::DarkGray)));
lines.push(TuiLine::from(meta));
if let Some(pending) = &pane.pending {
let risk_span = match pending.risk {
crate::risk::Risk::High => {
Span::styled("high · ", Style::default().add_modifier(Modifier::BOLD))
}
crate::risk::Risk::Low => Span::styled("low · ", tint(nc, Color::DarkGray)),
};
let mut detail = vec![Span::raw(" └ ")];
if top_pending_id.as_deref() == Some(pane.record_id.as_str()) {
detail.push(Span::styled(
"y·a·n ",
Style::default().add_modifier(Modifier::BOLD),
));
}
detail.push(risk_span);
detail.push(Span::raw(pending.title.clone()));
lines.push(TuiLine::from(detail));
} else if let Some((files, adds, dels)) = pane.review {
let mut detail = vec![Span::raw(" └ ")];
if at_cursor {
detail.push(Span::styled(
"m·p·D·r ",
Style::default().add_modifier(Modifier::BOLD),
));
}
detail.push(Span::raw(format!("review · {files}f ")));
detail.push(Span::raw(format!("+{adds}")));
detail.push(Span::raw("/"));
detail.push(Span::raw(format!("-{dels}")));
lines.push(TuiLine::from(detail));
}
row_spans.push((start, lines.len()));
if at_cursor {
cursor_end = lines.len();
}
}
if order.is_empty() {
lines.push(TuiLine::raw(""));
lines.push(TuiLine::styled("(no subagents)", tint(nc, Color::DarkGray)));
}
let scroll = scroll_to_cursor(cursor_end, chunks[0].height);
let roster = Paragraph::new(lines)
.block(Block::default().borders(Borders::LEFT))
.scroll((scroll, 0));
frame.render_widget(roster, chunks[0]);
for (row, (lo, hi)) in row_spans.into_iter().enumerate() {
if let Some((y, h)) = screen_span(chunks[0], scroll as usize, lo, hi) {
zones.push(ClickZone {
x: chunks[0].x,
y,
w: chunks[0].width,
h,
target: ClickTarget::RailRow(row),
});
}
}
let radar: Vec<Span> = order
.iter()
.map(|&idx| Span::raw(state_glyph(&state.agents[idx], state.tick).to_string()))
.collect();
frame.render_widget(
Paragraph::new(TuiLine::from(radar)).block(Block::default().borders(Borders::LEFT)),
chunks[1],
);
}
fn render_detail(state: &mut AppState, pty: &[PtyView], frame: &mut Frame, area: Rect) {
let nc = state.no_color;
let shown = state.detail.shown.clone();
let focus = state.detail.focus;
let split = state.detail.split;
if shown.is_empty() {
let placeholder = Paragraph::new(format!(
"no agent shown — {} n for a session · palette 'spawn subagent'",
state.leader_label()
))
.style(tint(nc, Color::DarkGray))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(placeholder, area);
return;
}
let rects = split_rects(area, shown.len(), split);
let mut pty_areas = Vec::new();
for (slot, (rid, rect)) in shown.iter().zip(rects.iter()).enumerate() {
if let Some(pane) = state.agents.iter_mut().find(|p| &p.record_id == rid) {
match pane.kind {
crate::tui::state::pane::PaneKind::Pty => {
pty_areas.push(crate::tui::state::layout::PtyArea {
record_id: rid.clone(),
x: rect.x.saturating_add(1),
y: rect.y.saturating_add(1),
cols: rect.width.saturating_sub(2),
rows: rect.height.saturating_sub(2),
});
let view = pty.iter().find(|v| &v.record_id == rid);
render_pty_pane(pane, view, slot, slot == focus, nc, frame, *rect);
}
crate::tui::state::pane::PaneKind::Monitor => {
render_pane(pane, slot, slot == focus, nc, frame, *rect)
}
}
}
}
state.pty_areas = pty_areas;
}
fn pane_block(title: String, focused: bool, nc: bool) -> Block<'static> {
if focused {
let bold = Style::default().add_modifier(Modifier::BOLD);
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Thick)
.border_style(bold)
.title(TuiLine::from(Span::styled(title, bold)))
} else {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.border_style(tint(nc, Color::DarkGray))
.title(title)
}
}
fn render_pty_pane(
pane: &mut PaneState,
view: Option<&PtyView>,
slot: usize,
focused: bool,
nc: bool,
frame: &mut Frame,
area: Rect,
) {
pane.viewport = area.height.saturating_sub(2) as usize;
let mut markers = String::new();
if pane.exited {
markers.push_str(" ✗");
}
let title = format!(
" [{}] {} · {}{} ",
slot + 1,
pane.agent_id,
pane.harness,
markers
);
let block = pane_block(title, focused, nc);
let block = if view.is_some_and(|v| v.scrolled) {
block.title_bottom(
TuiLine::from(Span::styled(
" ↑ SCROLLBACK · PgDn or type → live ",
tint(nc, Color::Yellow),
))
.right_aligned(),
)
} else {
block
};
let lines: Vec<TuiLine> = match view {
Some(v) => v.lines.clone(),
None => vec![TuiLine::styled(
"(starting…)",
tint(nc, Color::DarkGray).add_modifier(Modifier::ITALIC),
)],
};
let para = Paragraph::new(lines).block(block);
frame.render_widget(para, area);
}
fn split_rects(area: Rect, n: usize, split: Split) -> Vec<Rect> {
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![area];
}
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect();
let direction = match split {
Split::H => Direction::Horizontal,
Split::V => Direction::Vertical,
};
Layout::default()
.direction(direction)
.constraints(constraints)
.split(area)
.to_vec()
}
fn scroll_to_cursor(cursor_end: usize, height: u16) -> u16 {
u16::try_from(cursor_end.saturating_sub(height as usize)).unwrap_or(u16::MAX)
}
fn screen_span(area: Rect, scroll: usize, lo: usize, hi: usize) -> Option<(u16, u16)> {
let top = area.y as usize;
let vis_lo = lo.max(scroll);
let vis_hi = hi.min(scroll + area.height as usize);
if vis_lo >= vis_hi {
return None;
}
let y = top + (vis_lo - scroll);
Some((y as u16, (vis_hi - vis_lo) as u16))
}
fn render_pane(
pane: &mut PaneState,
slot: usize,
focused: bool,
nc: bool,
frame: &mut Frame,
area: Rect,
) {
let short = pane.record_id.get(..8).unwrap_or(pane.record_id.as_str());
let inner_height = area.height.saturating_sub(2) as usize;
let inner_width = area.width.saturating_sub(2) as usize;
pane.viewport = inner_height;
let extra = usize::from(pane.tail.is_some());
let total = pane.lines.len() + extra;
let tail_start = total.saturating_sub(inner_height);
let start = pane.scroll.map(|s| s.min(tail_start)).unwrap_or(tail_start);
let hidden_below = total - (start + inner_height).min(total);
let mut markers = String::new();
if pane.pending.is_some() {
markers.push_str(" ⚠");
}
if pane.attention {
markers.push_str(" ●");
}
if pane.exited {
markers.push_str(" ✗");
}
if hidden_below > 0 {
markers.push_str(&format!(" ⇣{hidden_below}"));
}
let harness = if pane.harness.is_empty() {
String::new()
} else {
format!(" · {}", pane.harness)
};
let title = format!(
" [{}] {}{} · {}{} ",
slot + 1,
pane.agent_id,
harness,
short,
markers
);
let block = pane_block(title, focused, nc);
let committed_end = pane.lines.len().min(start + inner_height);
let mut lines: Vec<TuiLine> = pane.lines[start.min(pane.lines.len())..committed_end]
.iter()
.map(|l| render_line(l, nc, inner_width))
.collect();
if let Some((kind, buf)) = &pane.tail
&& start + inner_height > pane.lines.len()
{
lines.push(match kind {
TailKind::Message => TuiLine::raw(buf.clone()),
TailKind::Thought => TuiLine::styled(buf.clone(), tint(nc, Color::DarkGray)),
});
}
if lines.is_empty() && !pane.exited {
lines.push(TuiLine::styled(
"thinking…",
tint(nc, Color::DarkGray).add_modifier(Modifier::ITALIC),
));
}
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
let para = if pane.scroll.is_none() {
let rows = para.line_count(area.width.saturating_sub(2));
let overflow = rows.saturating_sub(inner_height);
para.scroll((u16::try_from(overflow).unwrap_or(u16::MAX), 0))
} else {
para
};
frame.render_widget(para, area);
}
fn fmt_tokens(n: u64) -> String {
if n >= 1000 {
format!("{}k", n / 1000)
} else {
n.to_string()
}
}
fn fmt_cost(cost: &bitrouter_substrate::translate::UsageCost) -> String {
if cost.currency == "USD" {
format!(" ${:.2}", cost.amount)
} else {
format!(" {:.2} {}", cost.amount, cost.currency)
}
}
fn render_line(line: &Line, nc: bool, width: usize) -> TuiLine<'static> {
use bitrouter_substrate::translate::ToolStatus;
match line {
Line::Message(t) => TuiLine::raw(t.clone()),
Line::Thought(t) => TuiLine::styled(t.clone(), tint(nc, Color::DarkGray)),
Line::Code { text, lang } => TuiLine::from(crate::tui::highlight::spans(lang, text, nc)),
Line::Tool { title, status, .. } => {
let (glyph, color) = match status {
ToolStatus::Pending => ("· ", Color::DarkGray),
ToolStatus::Running => ("⚒ ", Color::Yellow),
ToolStatus::Ok => ("✓ ", Color::Green),
ToolStatus::Failed => ("✗ ", Color::Red),
};
TuiLine::from(vec![
Span::styled(glyph, tint(nc, color)),
Span::raw(title.clone()),
])
}
Line::Diff(d) => render_diff_line(d, nc, width),
Line::Error(t) => TuiLine::from(vec![
Span::styled("✗ ", tint(nc, Color::Red)),
Span::styled(t.clone(), tint(nc, Color::Red)),
]),
Line::AutoResolved(t) => TuiLine::from(vec![
Span::styled("· ", tint(nc, Color::DarkGray)),
Span::styled(t.clone(), tint(nc, Color::DarkGray)),
]),
Line::Note(t) => TuiLine::from(vec![
Span::styled("· ", tint(nc, Color::DarkGray)),
Span::styled(t.clone(), tint(nc, Color::DarkGray)),
]),
}
}
const ADD_BG: Color = Color::Rgb(16, 48, 16);
const DEL_BG: Color = Color::Rgb(48, 16, 16);
fn render_diff_line(d: &DiffLine, nc: bool, width: usize) -> TuiLine<'static> {
let pad = |s: &str| {
let mut out = s.to_string();
while out.chars().count() < width {
out.push(' ');
}
out
};
match d {
DiffLine::Header { path, adds, dels } => TuiLine::from(vec![
Span::styled(path.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(format!("+{adds}"), tint(nc, Color::Green)),
Span::raw("/"),
Span::styled(format!("-{dels}"), tint(nc, Color::Red)),
]),
DiffLine::Add(t) => {
let style = if nc {
Style::default()
} else {
Style::default().fg(Color::Green).bg(ADD_BG)
};
TuiLine::styled(pad(&format!("+{t}")), style)
}
DiffLine::Del(t) => {
let style = if nc {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(Color::Red)
.bg(DEL_BG)
.add_modifier(Modifier::DIM)
};
TuiLine::styled(pad(&format!("-{t}")), style)
}
DiffLine::Ctx(t) => TuiLine::styled(format!(" {t}"), tint(nc, Color::DarkGray)),
DiffLine::Gap => TuiLine::styled("⋮", tint(nc, Color::DarkGray)),
}
}
fn render_statusbar(state: &AppState, zones: &mut Vec<ClickZone>, frame: &mut Frame, area: Rect) {
let nc = state.no_color;
let pty_focused = state
.focused()
.is_some_and(|p| p.kind == crate::tui::state::pane::PaneKind::Pty);
let leader = state.leader_label();
let hints = match state.mode {
Mode::Normal if pty_focused => {
format!("⇢ keys go to the orchestrator · {leader} menu")
}
Mode::Normal => format!("{leader} menu"),
Mode::Leader => "LEADER".to_string(),
Mode::Picker => "PICKER".to_string(),
Mode::Command => "COMMAND".to_string(),
Mode::Confirm => "CONFIRM".to_string(),
};
let left = match &state.notice {
Some(n) => format!("! {n}"),
None => {
let mut parts: Vec<String> = Vec::new();
if let Some(pane) = state.focused() {
match pane.usage {
Some((used, size)) if size > 0 => {
parts.push(format!("ctx {}%", used * 100 / size));
}
Some((used, _)) if used > 0 => {
parts.push(format!("ctx {}", fmt_tokens(used)));
}
_ => {}
}
if let Some(model) = &pane.model {
parts.push(model.clone());
}
if let Some(cost) = &pane.cost {
parts.push(fmt_cost(cost).trim_start().to_string());
}
}
if parts.is_empty() {
hints.clone()
} else {
format!("{} {}", parts.join(" · "), hints)
}
}
};
let mut segments: Vec<String> = Vec::new();
let badge: Vec<String> = state
.badge_counts()
.into_iter()
.map(|(glyph, n)| format!("{glyph}{n}"))
.collect();
if !badge.is_empty() {
segments.push(badge.join(" "));
}
if let Some(total) = state.total_cost() {
segments.push(fmt_cost(&total).trim_start().to_string());
}
let serve = match state.serve_ok {
Some(true) => Some("serve ●"),
Some(false) => Some("serve ✗"),
None => None,
};
let mut right = segments.join(" · ");
if serve.is_some() && !right.is_empty() {
right.push_str(" · ");
}
let right_chars = right.chars().count() + serve.map_or(0, |s| s.chars().count());
const BTN: usize = 3; let full = area.width as usize;
let buttons = full >= BTN * 2 + 4;
let width = if buttons { full - BTN * 2 } else { full };
let mut left = left;
if state.notice.is_none() && right_chars > 0 && left.chars().count() + right_chars + 2 > width {
let keep = width.saturating_sub(right_chars + 3);
left = left.chars().take(keep).collect();
if !left.is_empty() {
left.push('…');
}
}
let left_chars = left.chars().count();
let mut inner: Vec<Span> = Vec::new();
if right_chars > 0 && left_chars + right_chars + 2 <= width {
let pad = width - left_chars - right_chars;
inner.push(Span::raw(left));
inner.push(Span::raw(" ".repeat(pad)));
inner.push(Span::styled(right, tint(nc, Color::DarkGray)));
if let Some(text) = serve {
inner.push(Span::styled(text, tint(nc, Color::DarkGray)));
}
} else {
if left.chars().count() > width {
left = left.chars().take(width).collect();
}
let pad = width - left.chars().count();
inner.push(Span::raw(left));
if pad > 0 {
inner.push(Span::raw(" ".repeat(pad)));
}
}
let mut spans = Vec::new();
if buttons {
let l_btn = if state.sessions_collapsed {
">> "
} else {
"<< "
};
let r_btn = if state.subagents_collapsed {
" <<"
} else {
" >>"
};
spans.push(Span::styled(l_btn, tint(nc, Color::DarkGray)));
spans.extend(inner);
spans.push(Span::styled(r_btn, tint(nc, Color::DarkGray)));
zones.push(ClickZone {
x: area.x,
y: area.y,
w: BTN as u16,
h: 1,
target: ClickTarget::ToggleSessions,
});
zones.push(ClickZone {
x: area.x + area.width - BTN as u16,
y: area.y,
w: BTN as u16,
h: 1,
target: ClickTarget::ToggleSubagents,
});
} else {
spans.extend(inner);
}
frame.render_widget(Paragraph::new(TuiLine::from(spans)), area);
}
fn render_picker(picker: &PickerState, frame: &mut Frame, area: Rect) {
let popup = centered(area, 50, 50);
frame.render_widget(Clear, popup);
let items: Vec<TuiLine> = if picker.agents.is_empty() {
vec![TuiLine::raw("(no agents configured)")]
} else {
picker
.agents
.iter()
.enumerate()
.map(|(i, a)| {
if i == picker.selected {
TuiLine::styled(
format!("> {a}"),
Style::default().add_modifier(Modifier::BOLD),
)
} else {
TuiLine::raw(format!(" {a}"))
}
})
.collect()
};
let title = match picker.purpose {
PickerPurpose::Subagent => " pick agent ",
PickerPurpose::Session => " new session ",
};
let para = Paragraph::new(items).block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(para, popup);
}
fn render_permission(pending: &PendingView, nc: bool, frame: &mut Frame, area: Rect) {
let popup = centered(area, 70, 40);
frame.render_widget(Clear, popup);
let width = popup.width.saturating_sub(2) as usize;
let mut lines: Vec<TuiLine> = vec![TuiLine::raw(pending.title.clone())];
if let Some(diff) = &pending.diff {
for l in diff_lines(diff) {
lines.push(render_line(&l, nc, width));
}
}
let keys: Vec<String> = pending
.options
.iter()
.map(|o| format!("[{}] {}", key_for(&o.label), o.label))
.collect();
lines.push(TuiLine::raw(keys.join(" ")));
let para = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" permission "))
.wrap(Wrap { trim: false });
frame.render_widget(para, popup);
}
fn key_for(label: &str) -> char {
match label {
"allow" => 'y',
"allow always" => 'a',
_ => 'n',
}
}
fn centered(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(v[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::AppState;
use crate::tui::state::diff::Line;
use crate::tui::state::layout::DetailLayout;
use crate::tui::state::overlay::{Mode, PickerPurpose, PickerState};
use crate::tui::state::pane::PaneState;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
fn draw(state: &mut AppState, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(state, &[], f)).expect("draw");
terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
fn agents3() -> AppState {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.agents.push(PaneState::new("r1".into(), "a1".into()));
st.agents.push(PaneState::new("r2".into(), "a2".into()));
st
}
#[test]
fn rail_lists_every_agent_even_when_detail_is_solo() {
let text = draw(&mut agents3(), 80, 24);
assert!(text.contains("a0") && text.contains("a1") && text.contains("a2"));
assert!(text.contains("subagents"), "rail header");
}
fn with_session() -> AppState {
let mut st = agents3();
let mut orch = PaneState::new("orchestrator".into(), "claude".into());
orch.kind = crate::tui::state::pane::PaneKind::Pty;
orch.harness = "pty".into();
orch.model = Some("supergrok:grok-4.5".into());
st.agents.push(orch);
st
}
fn render_keeping_state(state: &mut AppState, w: u16, h: u16) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(state, &[], f)).expect("draw");
}
#[test]
fn renderer_records_click_zones_for_buttons_and_rows() {
let mut st = with_session();
render_keeping_state(&mut st, 130, 30);
let has = |t: ClickTarget| st.click_zones.iter().any(|z| z.target == t);
assert!(has(ClickTarget::ToggleSessions), "<< button zone recorded");
assert!(has(ClickTarget::ToggleSubagents), ">> button zone recorded");
assert!(
st.click_zones
.iter()
.any(|z| matches!(z.target, ClickTarget::SessionRow(_))),
"a session row is clickable"
);
assert!(
st.click_zones
.iter()
.any(|z| matches!(z.target, ClickTarget::RailRow(_))),
"a subagent row is clickable"
);
}
#[test]
fn a_click_at_a_recorded_rail_zone_opens_that_agent() {
let mut st = with_session();
render_keeping_state(&mut st, 130, 30);
let target = st.roster()[1];
let target_id = st.agents[target].record_id.clone();
let zone = st
.click_zones
.iter()
.find(|z| z.target == ClickTarget::RailRow(1))
.copied()
.expect("a RailRow(1) zone");
let (col, row) = (zone.x + zone.w / 2, zone.y + zone.h / 2);
crate::tui::state::reduce(&mut st, &crate::tui::event::AppEvent::Click { col, row });
assert_eq!(
st.detail.shown,
vec![target_id],
"clicked rail row opened solo"
);
assert_eq!(
st.mode,
Mode::Normal,
"no mode to enter — click just focuses"
);
}
#[test]
fn sessions_panel_shows_binary_model_and_footer_on_wide_terminals() {
let text = draw(&mut with_session(), 130, 30);
assert!(text.contains("sessions"), "left header");
assert!(text.contains("claude"), "session binary");
assert!(text.contains("supergrok:grok-4.5"), "session model");
assert!(text.contains("+ new session"), "footer affordance");
}
#[test]
fn sessions_panel_folds_on_narrow_terminals_and_on_collapse() {
let mut st = with_session();
let text = draw(&mut st, 80, 24);
assert!(!text.contains("+ new session"), "auto-collapsed at 80 cols");
st.sessions_collapsed = true;
let text = draw(&mut st, 130, 30);
assert!(!text.contains("+ new session"), "user collapse wins");
st.subagents_collapsed = true;
let text = draw(&mut st, 130, 30);
assert!(!text.contains("subagents"), "rail hidden");
}
#[test]
fn empty_sessions_panel_shows_when_wide_folds_when_tight() {
let text = draw(&mut agents3(), 130, 30);
assert!(text.contains("+ new session"), "affordance on wide: {text}");
let text = draw(&mut agents3(), 90, 30);
assert!(!text.contains("+ new session"), "empty panel folds");
assert!(text.contains("subagents"), "content panel survives");
}
#[test]
fn composer_never_renders() {
let mut st = with_session();
st.detail = DetailLayout {
shown: vec!["orchestrator".into()],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
let text = draw(&mut st, 140, 24);
assert!(!text.contains("› "), "no idle prompt under a PTY pane");
assert!(
text.contains("keys go to the orchestrator"),
"hint moved to the status bar"
);
let boxes_under_pty = text.matches('┌').count();
st.detail = DetailLayout {
shown: vec!["r0".into()],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
let text = draw(&mut st, 140, 24);
assert!(!text.contains("› "), "no composer for a Monitor pane");
assert_eq!(
text.matches('┌').count(),
boxes_under_pty,
"no extra input box appears when a Monitor takes focus"
);
}
#[test]
fn status_bar_left_zone_follows_the_focused_pane() {
let mut st = with_session();
st.detail = DetailLayout {
shown: vec!["r0".into()],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
st.agents[0].usage = Some((62_000, 100_000));
st.agents[0].model = Some("claude-opus-4-8".into());
st.agents[0].cost = Some(bitrouter_substrate::translate::UsageCost {
amount: 0.41,
currency: "USD".into(),
});
let text = draw(&mut st, 140, 24);
assert!(
text.contains("ctx 62%"),
"context gauge in the bar: {text:?}"
);
assert!(text.contains("claude-opus-4-8"), "model tag in the bar");
assert!(text.contains("$0.41"), "pane cost in the bar");
st.notice = Some("previous fleet remembered".into());
let text = draw(&mut st, 140, 24);
assert!(text.contains("! previous fleet remembered"));
assert!(!text.contains("ctx 62%"), "notice preempts the gauge");
}
#[test]
fn status_bar_right_zone_reports_global_state() {
let mut st = with_session();
st.serve_ok = Some(true);
st.agents[0].cost = Some(bitrouter_substrate::translate::UsageCost {
amount: 0.30,
currency: "USD".into(),
});
st.agents[1].cost = Some(bitrouter_substrate::translate::UsageCost {
amount: 0.12,
currency: "USD".into(),
});
st.agents[2].attention = true;
let (w, h) = (130u16, 30u16);
let text = draw(&mut st, w, h);
let row: String = text.chars().skip((w as usize) * (h as usize - 1)).collect();
let bar: String = match (row.find("<<"), row.rfind(">>")) {
(Some(lo), Some(hi)) if lo < hi => row[lo..hi].to_string(),
_ => row,
};
assert!(bar.contains("serve ●"), "live daemon dot: {bar}");
assert!(!bar.contains("session"), "bare session count folded: {bar}");
assert!(bar.contains("$0.42"), "summed fleet cost");
assert!(bar.contains("●1"), "attention count");
st.serve_ok = Some(false);
let text = draw(&mut st, w, h);
assert!(text.contains("serve ✗"));
}
#[test]
fn subagent_rows_carry_a_state_and_harness_meta_line() {
let mut st = agents3();
st.agents[1].harness = "claude".into();
st.agents[1].turn_active = true;
let text = draw(&mut st, 80, 24);
assert!(text.contains("working · claude"), "meta line: {text}");
assert!(text.contains("idle"), "calm rows say idle");
}
#[test]
fn rail_sorts_actionable_agent_to_the_top() {
let mut st = agents3();
st.agents[2].pending = Some(crate::tui::state::pane::PendingView {
title: "WRITE".into(),
diff: None,
options: vec![],
risk: crate::risk::Risk::High,
});
st.detail = DetailLayout {
shown: vec!["r2".into()],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
let text = draw(&mut st, 80, 24);
let (a2, a0) = (text.find("a2"), text.find("a0"));
assert!(
a2 < a0,
"needs-you row renders above running rows: a2={a2:?} a0={a0:?}"
);
assert!(text.contains('⚠'), "needs-you glyph shown");
}
#[test]
fn detail_shows_only_shown_agents() {
let mut st = agents3();
st.agents[1]
.lines
.push(Line::Message("SECOND_PANE_UNIQUE".into()));
let text = draw(&mut st, 80, 24);
assert!(
!text.contains("SECOND_PANE_UNIQUE"),
"non-shown agent content hidden"
);
assert!(text.contains("[1]"), "solo pane has slot header");
}
#[test]
fn split_shows_two_panes_with_slots() {
let mut st = agents3();
st.detail = DetailLayout {
shown: vec!["r0".into(), "r1".into()],
split: crate::tui::state::layout::Split::H,
focus: 1,
};
st.agents[0]
.lines
.push(Line::Message("LEFT_CONTENT".into()));
st.agents[1]
.lines
.push(Line::Message("RIGHT_CONTENT".into()));
let text = draw(&mut st, 100, 24);
assert!(text.contains("LEFT_CONTENT") && text.contains("RIGHT_CONTENT"));
assert!(text.contains("[1]") && text.contains("[2]"));
}
#[test]
fn pane_header_includes_harness_tag() {
let mut st = AppState::new(PaneState::new("r0".into(), "api-1".into()));
st.agents[0].harness = "codex".into();
let text = draw(&mut st, 80, 24);
assert!(
text.contains("api-1 · codex"),
"agent · harness header: {text:?}"
);
}
#[test]
fn split_rects_h_columns_v_rows_no_overlap() {
let area = Rect::new(0, 0, 80, 24);
for n in 1..=4usize {
for split in [
crate::tui::state::layout::Split::H,
crate::tui::state::layout::Split::V,
] {
let rects = split_rects(area, n, split);
assert_eq!(rects.len(), n, "n={n} rect count");
for i in 0..rects.len() {
for j in (i + 1)..rects.len() {
assert!(!overlaps(rects[i], rects[j]), "n={n} rects {i},{j} overlap");
}
}
}
}
let h = split_rects(area, 2, crate::tui::state::layout::Split::H);
assert_eq!(h[0].y, h[1].y, "H = side-by-side");
let v = split_rects(area, 2, crate::tui::state::layout::Split::V);
assert_eq!(v[0].x, v[1].x, "V = stacked");
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height
}
#[test]
fn rail_shows_attention_glyph_for_background_agent() {
let mut st = agents3();
st.agents[1].attention = true;
let text = draw(&mut st, 80, 24);
assert!(text.contains('●'), "attention glyph rendered in rail/radar");
}
#[test]
fn rail_shows_done_unseen_glyph() {
let mut st = agents3();
st.agents[1].done = true;
let text = draw(&mut st, 80, 24);
assert!(text.contains('◉'), "done-unseen glyph rendered: {text:?}");
}
#[test]
fn rail_shows_time_in_state_for_working_rows() {
let mut st = agents3();
st.agents[0].turn_active = true;
crate::tui::state::reduce(&mut st, &crate::tui::event::AppEvent::Tick);
st.tick += 42 * 5;
let text = draw(&mut st, 80, 24);
assert!(text.contains("42s"), "elapsed column rendered: {text:?}");
}
#[test]
fn picker_overlay_lists_agents() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.mode = Mode::Picker;
st.picker = Some(PickerState {
agents: vec!["alpha".into(), "beta".into()],
selected: 0,
purpose: PickerPurpose::Subagent,
});
let text = draw(&mut st, 80, 24);
assert!(text.contains("alpha") && text.contains("beta"));
}
#[test]
fn single_message_line_renders_with_agent_title() {
let mut pane = PaneState::new("rec-1".into(), "claude".into());
pane.lines.push(Line::Message("hello world".into()));
let text = draw(&mut AppState::new(pane), 60, 12);
assert!(text.contains("hello world"));
assert!(text.contains("claude"));
}
#[test]
fn pinned_pane_shows_off_tail_indicator_and_history() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
for i in 0..40 {
st.agents[0]
.lines
.push(Line::Message(format!("hist{i}end")));
}
st.agents[0].scroll = Some(0);
let text = draw(&mut st, 60, 12);
assert!(text.contains('⇣'), "off-tail indicator visible: {text:?}");
assert!(text.contains("hist0end"), "pinned view shows history top");
assert!(!text.contains("hist39end"), "tail hidden while pinned");
st.agents[0].scroll = None;
let text = draw(&mut st, 60, 12);
assert!(!text.contains('⇣'), "no indicator when following the tail");
assert!(text.contains("hist39end"), "tail visible when following");
}
#[test]
fn rail_expands_pending_row_with_title_and_resolve_hint() {
let mut st = agents3();
st.agents[1].pending = Some(crate::tui::state::pane::PendingView {
title: "rm -rf".into(),
diff: None,
options: vec![],
risk: crate::risk::Risk::High,
});
st.detail = DetailLayout {
shown: vec!["r1".into()],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
let text = draw(&mut st, 100, 24);
assert!(text.contains("rm -rf"), "pending title inline");
assert!(text.contains('└'), "expanded row marker");
assert!(text.contains("y·a·n"), "resolve hint on the queue-top row");
}
#[test]
fn rail_shows_risk_label_and_autonomy_tag() {
let mut st = agents3();
st.agents[1].pending = Some(crate::tui::state::pane::PendingView {
title: "wants".into(),
diff: None,
options: vec![],
risk: crate::risk::Risk::High,
});
st.agents[2].autonomy = crate::tui::state::pane::Autonomy::Auto;
let text = draw(&mut st, 80, 24);
assert!(text.contains("high ·"), "risk label on the expanded row");
assert!(text.contains("[A]"), "auto tier tagged on the row");
}
#[test]
fn palette_popup_renders_filter_and_matches() {
let mut st = agents3();
st.mode = Mode::Command;
st.palette = Some(crate::tui::state::overlay::PaletteState {
input: "sp".into(),
selected: 0,
});
let text = draw(&mut st, 80, 24);
assert!(text.contains("spawn subagent"), "match listed");
assert!(text.contains("> spawn subagent"), "selection marked");
}
#[test]
fn palette_popup_handles_no_matches() {
let mut st = agents3();
st.mode = Mode::Command;
st.palette = Some(crate::tui::state::overlay::PaletteState {
input: "zzz".into(),
selected: 3,
});
let text = draw(&mut st, 80, 24);
assert!(text.contains("no matching command"), "empty state shown");
}
#[test]
fn keys_help_popup_lists_mode_bindings() {
let mut st = agents3();
st.mode = Mode::Leader;
st.keys_help = true;
let text = draw(&mut st, 90, 30);
for (_, what, _) in LEADER_LEAVES {
assert!(text.contains(what), "leader overlay lists {what:?}");
}
assert!(text.contains("focus session N"));
assert!(text.contains("cancel"));
}
#[test]
fn streaming_tail_renders_after_committed_lines() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.agents[0].lines.push(Line::Message("committed".into()));
st.agents[0].tail = Some((TailKind::Message, "half-formed".into()));
let text = draw(&mut st, 60, 12);
assert!(text.contains("committed"));
assert!(text.contains("half-formed"), "mutable tail visible");
}
#[test]
fn diff_lines_render_with_prefixes_and_chips() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
for l in crate::tui::state::diff::diff_lines(&crate::tui::event::DiffData {
path: "src/x.rs".into(),
old: "old line\n".into(),
new: "new line\n".into(),
}) {
st.agents[0].lines.push(l);
}
let text = draw(&mut st, 60, 12);
assert!(text.contains("src/x.rs +1/-1"), "header chips: {text:?}");
assert!(text.contains("-old line"), "deletion prefixed");
assert!(text.contains("+new line"), "addition prefixed");
}
#[test]
fn code_lines_render_their_text() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.agents[0].lines.push(Line::Code {
text: "fn main() {}".into(),
lang: "rust".into(),
});
let text = draw(&mut st, 60, 12);
assert!(text.contains("fn main() {}"));
}
#[test]
fn cost_shows_in_rail_and_status_bar() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.agents[0].cost = Some(bitrouter_substrate::translate::UsageCost {
amount: 0.25,
currency: "USD".into(),
});
let text = draw(&mut st, 80, 24);
assert!(text.contains("$0.25"), "cost column rendered: {text:?}");
}
#[test]
fn confirm_overlay_shows_the_bootstrap_command() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.mode = Mode::Confirm;
st.bootstrap_cmd = Some("npm ci".into());
st.confirm_agent = Some("codex".into());
let text = draw(&mut st, 90, 24);
assert!(text.contains("npm ci"), "the shell it will run is visible");
assert!(text.contains("codex"), "which spawn is waiting");
assert!(
text.contains("[y]") && text.contains("[Esc]"),
"resolve keys"
);
}
#[test]
fn pty_pane_renders_the_grid_and_records_its_size() {
let mut pane = PaneState::new("orchestrator".into(), "claude".into());
pane.kind = crate::tui::state::pane::PaneKind::Pty;
pane.harness = "pty".into();
let mut st = AppState::new(pane);
let view = PtyView {
record_id: "orchestrator".into(),
lines: vec![
ratatui::text::Line::raw("NATIVE_TUI_ROW_1"),
ratatui::text::Line::raw("NATIVE_TUI_ROW_2"),
],
scrolled: false,
};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| render(&mut st, std::slice::from_ref(&view), f))
.expect("draw");
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("NATIVE_TUI_ROW_1"), "grid rendered: {text:?}");
assert!(text.contains("claude · pty"), "pane header");
assert!(
text.contains("keys go to the orchestrator"),
"passthrough hint replaces the prompt line"
);
let area = st.pty_areas.first().expect("drawn size recorded");
assert_eq!(area.record_id, "orchestrator");
assert!(area.cols > 0 && area.rows > 0);
assert!(
area.x > 0 && area.y > 0,
"content origin sits inside the border"
);
}
#[test]
fn pty_pane_shows_a_scrollback_hint_when_pinned() {
let mut pane = PaneState::new("orchestrator".into(), "claude".into());
pane.kind = crate::tui::state::pane::PaneKind::Pty;
pane.harness = "pty".into();
let mut st = AppState::new(pane);
let view = PtyView {
record_id: "orchestrator".into(),
lines: vec![ratatui::text::Line::raw("OLD_HISTORY_ROW")],
scrolled: true,
};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| render(&mut st, std::slice::from_ref(&view), f))
.expect("draw");
let text: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("SCROLLBACK"),
"a pinned view surfaces the scrollback hint: {text:?}"
);
}
#[test]
fn pty_pane_without_a_view_shows_a_calm_placeholder() {
let mut pane = PaneState::new("orchestrator".into(), "claude".into());
pane.kind = crate::tui::state::pane::PaneKind::Pty;
let mut st = AppState::new(pane);
let text = draw(&mut st, 60, 12);
assert!(text.contains("starting…"), "{text:?}");
}
#[test]
fn rail_shows_allocated_port() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
st.agents[0].port = Some(3101);
let text = draw(&mut st, 80, 24);
assert!(text.contains(":3101"), "port column rendered: {text:?}");
}
#[test]
fn idle_agent_shows_idle_glyph_not_spinner() {
let mut st = agents3(); let text = draw(&mut st, 80, 24);
assert!(text.contains('○'), "idle glyph");
assert!(!text.contains('⣾'), "no spinner without a turn");
}
#[test]
fn pre_first_output_pane_shows_thinking_placeholder() {
let mut st = AppState::new(PaneState::new("r0".into(), "a0".into()));
let text = draw(&mut st, 60, 12);
assert!(text.contains("thinking…"), "calm placeholder, not blank");
st.agents[0].exited = true;
let text = draw(&mut st, 60, 12);
assert!(!text.contains("thinking…"), "dead pane doesn't pretend");
}
#[test]
fn spinner_advances_with_tick() {
let mut st = agents3();
st.agents[0].turn_active = true; st.tick = 0;
let t0 = draw(&mut st, 80, 24);
st.tick = 1;
let t1 = draw(&mut st, 80, 24);
assert!(t0.contains('⣾') && !t0.contains('⣽'), "frame 0");
assert!(t1.contains('⣽') && !t1.contains('⣾'), "frame 1");
}
#[test]
fn no_color_strips_foregrounds_but_keeps_glyphs() {
use ratatui::style::Color;
let mut st = agents3();
st.agents[1].attention = true;
st.no_color = true;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(&mut st, &[], f)).expect("draw");
let buffer = terminal.backend().buffer();
let colored = buffer
.content()
.iter()
.filter(|c| c.fg != Color::Reset)
.count();
assert_eq!(colored, 0, "NO_COLOR leaves no foreground colors");
let text: String = buffer.content().iter().map(|c| c.symbol()).collect();
assert!(text.contains('●'), "state glyphs still carry the meaning");
}
#[test]
fn empty_agent_list_renders_placeholders() {
let mut st = agents3();
st.agents.clear();
st.detail = DetailLayout {
shown: vec![],
split: crate::tui::state::layout::Split::H,
focus: 0,
};
let text = draw(&mut st, 80, 24);
assert!(text.contains("(no subagents)"), "rail placeholder");
assert!(text.contains("no agent shown"), "detail placeholder");
}
#[test]
fn tiny_terminals_render_every_surface_without_panic() {
use crate::tui::event::PermOption;
use crate::tui::state::pane::PendingView;
use bitrouter_substrate::translate::PermissionOutcome;
let mut st = agents3();
st.detail = DetailLayout {
shown: vec!["r0".into(), "r1".into()],
split: crate::tui::state::layout::Split::V,
focus: 0,
};
st.agents[0].pending = Some(PendingView {
title: "write file".into(),
diff: Some(crate::tui::event::DiffData {
path: "src/x.rs".into(),
old: "removed\n".into(),
new: "added\n".into(),
}),
options: vec![PermOption {
outcome: PermissionOutcome::AllowOnce,
label: "allow".into(),
}],
risk: crate::risk::Risk::High,
});
st.agents[1].attention = true;
st.mode = Mode::Picker;
st.picker = Some(PickerState {
agents: vec!["alpha".into()],
selected: 0,
purpose: PickerPurpose::Subagent,
});
st.notice = Some("spawn failed".into());
for (w, h) in [(1, 1), (2, 2), (5, 3), (10, 2), (20, 5), (80, 1), (1, 24)] {
let _ = draw(&mut st, w, h);
}
}
}