use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use super::app::{
App, KEY_HELP, Load, LoadSessions, ManageFocus, MenuFocus, PaneBox, PaneRects, Row, RowKind,
Screen,
};
use super::theme::Theme;
const BANNER: &str = r#"██████ █████ ██████ ██ ██ ██ █████ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ███████ ██ ██ ██ █ ██ ███████ ████
██ ██ ██ ██ ██ ██ ███████ ██ ██ ██
██ ██ ██ ██ ██████ ███████ ██ ██ ██ ██ ██"#;
const BANNER_W: u16 = 55;
const BANNER_H: u16 = 5;
const LABEL_W: usize = 20;
const CARD_GUTTER: usize = 3;
const TREE_W: u16 = 32;
fn chord_spans(theme: &Theme, chords: &[(&str, &str)]) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(chords.len() * 2);
for (chord, what) in chords {
spans.push(Span::styled(
format!(" {chord} "),
Style::default()
.fg(theme.on_accent)
.bg(theme.accent_dim)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {what} "),
Style::default().fg(theme.dim),
));
}
spans
}
fn banner_lines(theme: &Theme) -> Vec<Line<'static>> {
BANNER
.lines()
.map(|l| {
let pad = (BANNER_W as usize).saturating_sub(l.chars().count());
Line::from(Span::styled(
format!("{l}{}", " ".repeat(pad)),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
})
.collect()
}
pub fn render_with_layout(app: &App, f: &mut Frame) -> (PaneRects, Option<String>) {
let mut rects = PaneRects::default();
render_inner(app, f, &mut rects);
let text = app.pending_copy.and_then(|selection| {
let bounds = match selection.pane {
ManageFocus::Tree => rects.tree,
ManageFocus::Session => rects.session,
};
let buffer = f.buffer_mut();
let lines: Vec<String> = selection
.spans(bounds)
.into_iter()
.map(|(y, x0, x1)| {
let line: String = (x0..=x1).map(|x| buffer[(x, y)].symbol()).collect();
line.trim_end().to_string()
})
.collect();
let text = lines.join("\n");
(!text.trim().is_empty()).then_some(text)
});
(rects, text)
}
fn render_inner(app: &App, f: &mut Frame, rects: &mut PaneRects) {
f.render_widget(Clear, f.area());
render_screen(app, f, rects);
render_toast(app, f);
}
fn render_toast(app: &App, f: &mut Frame) {
let Some(toast) = app.toast.as_ref().filter(|toast| !toast.expired()) else {
return;
};
let theme = app.theme;
let area = f.area();
let text = format!(" {} {} ", if toast.ok { "✓" } else { "✕" }, toast.text);
let w = (text.chars().count() as u16 + 2).min(area.width);
let h = 3.min(area.height);
let rect = Rect {
x: area.width.saturating_sub(w + 2),
y: area.height.saturating_sub(h + 2),
width: w,
height: h,
};
let accent = if toast.ok {
theme.accent
} else {
theme.pending
};
f.render_widget(Clear, rect);
f.render_widget(
Paragraph::new(Span::styled(text, Style::default().fg(theme.fg))).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent)),
),
rect,
);
}
fn render_screen(app: &App, f: &mut Frame, rects: &mut PaneRects) {
match app.screen {
Screen::Setup => {
render_menu(app, f, rects);
render_wizard(app, f);
}
Screen::Menu => render_menu(app, f, rects),
Screen::Manage => render_manage(app, f, rects),
Screen::TargetPick => {
render_menu(app, f, rects);
render_target_pick(app, f);
}
Screen::AgentPick => {
render_menu(app, f, rects);
render_agent_pick(app, f);
}
}
}
fn whole(area: Rect) -> PaneBox {
PaneBox {
x: area.x,
y: area.y,
w: area.width,
h: area.height,
}
}
fn interior(area: Rect) -> PaneBox {
PaneBox {
x: area.x + 1,
y: area.y + 1,
w: area.width.saturating_sub(2),
h: area.height.saturating_sub(2),
}
}
fn centered(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}
fn render_menu(app: &App, f: &mut Frame, rects: &mut PaneRects) {
let theme = app.theme;
let area = f.area();
let big = area.width >= BANNER_W + 8 && area.height >= 26;
let banner_h = if big { BANNER_H } else { 1 };
let prompt_h = if area.height >= 30 { 6 } else { 4 };
let panel_w = 74.min(area.width.saturating_sub(2)).max(40.min(area.width));
let cards = app.cards();
let chrome = banner_h + prompt_h + 12;
let mut block = card_block(&cards, panel_w as usize, true);
if chrome + block.height(cards.len()) > area.height {
block = card_block(&cards, panel_w as usize, false);
}
let cards_h = block.height(cards.len());
let panel_h = chrome + cards_h;
let panel = centered(panel_w, panel_h.min(area.height), area);
let rows = Layout::vertical([
Constraint::Length(banner_h),
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(prompt_h),
Constraint::Length(1), Constraint::Length(cards_h),
Constraint::Min(0),
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(panel);
let wordmark = if big {
Paragraph::new(banner_lines(theme))
} else {
Paragraph::new("RAILWAY CLOUD-AGENTS").style(
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
};
f.render_widget(wordmark.alignment(Alignment::Center), rows[0]);
if big {
f.render_widget(
Paragraph::new("CLOUD AGENTS")
.alignment(Alignment::Center)
.style(Style::default().fg(theme.accent)),
rows[2],
);
}
f.render_widget(
Paragraph::new("What should we build today?")
.alignment(Alignment::Center)
.style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
rows[3],
);
if !app.status.is_empty() {
f.render_widget(
Paragraph::new(app.status.clone())
.alignment(Alignment::Center)
.style(Style::default().fg(theme.accent)),
rows[4],
);
}
render_prompt(app, f, rows[6]);
rects.prompt = whole(rows[6]);
render_cards(app, f, rows[8], &block, rects);
f.render_widget(
Paragraph::new(target_line(app)).alignment(Alignment::Center),
rows[10],
);
let chords: &[(&str, &str)] = match app.menu_focus {
MenuFocus::Prompt => &[
("enter", "launch"),
("shift+tab", "agent"),
("^t", "target"),
("⌥t", "theme"),
("⌥s", "setup"),
],
MenuFocus::Cards => &[
("↑↓", "select"),
("enter", "open"),
("^t", "target"),
("⌥t", "theme"),
("⌥s", "setup"),
("q", "quit"),
],
};
f.render_widget(
Paragraph::new(Line::from(chord_spans(theme, chords))).alignment(Alignment::Center),
rows[12],
);
}
fn target_line(app: &App) -> Line<'static> {
let theme = app.theme;
let label = Span::styled(
"Target Project ",
Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
);
match app.target.as_ref() {
Some(target) => Line::from(vec![
label,
Span::styled(
format!("{} ({})", target.project_name, target.environment_name),
Style::default().fg(theme.accent),
),
]),
None => Line::from(vec![
label,
Span::styled("not set", Style::default().fg(theme.pending)),
]),
}
}
fn render_loading(app: &App, f: &mut Frame, area: Rect) {
let theme = app.theme;
let loading = &app.loading;
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
format!(" {} · starting ", loading.harness),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
let area = {
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
const STEP_ROWS: u16 = 9;
let task_h = match loading.prompt.as_deref() {
Some(_) => 5,
None => 0,
};
let content_w = loading
.steps
.iter()
.map(|step| step.chars().count() + 2)
.chain(std::iter::once(loading.target.chars().count()))
.max()
.unwrap_or(0)
.clamp(20, area.width.max(1) as usize) as u16;
let panel = centered(content_w, (STEP_ROWS + task_h + 4).min(area.height), area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(task_h),
Constraint::Length(STEP_ROWS),
Constraint::Min(0),
Constraint::Length(1), ])
.split(panel);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("{} ", spinner_frame(loading.tick)),
Style::default().fg(theme.accent),
),
Span::styled(
loading.target.clone(),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
]))
.alignment(Alignment::Center),
rows[0],
);
f.render_widget(
Paragraph::new("preparing the agent")
.alignment(Alignment::Center)
.style(Style::default().fg(theme.dim)),
rows[1],
);
if let Some(prompt) = loading.prompt.as_deref() {
let task_area = centered((area.width / 3).max(24), task_h, rows[3]);
f.render_widget(
Paragraph::new(prompt.to_string())
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent_dim))
.title(Span::styled(" Task ", Style::default().fg(theme.dim))),
)
.style(Style::default().fg(theme.fg))
.wrap(Wrap { trim: true }),
task_area,
);
}
f.render_widget(
Paragraph::new(step_lines(app, rows[4].width)).wrap(Wrap { trim: false }),
rows[4],
);
}
fn spinner_frame(tick: usize) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[tick % FRAMES.len()]
}
fn step_lines(app: &App, width: u16) -> Vec<Line<'static>> {
let theme = app.theme;
let steps = &app.loading.steps;
const STEP_ROWS: usize = 9;
let usable = width.saturating_sub(2).max(20) as usize;
let mut budget = STEP_ROWS;
let mut start = steps.len();
for (i, step) in steps.iter().enumerate().rev() {
let rows = step.chars().count().div_ceil(usable).max(1);
if rows > budget {
break;
}
budget -= rows;
start = i;
}
let last = steps.len().saturating_sub(1);
steps[start..]
.iter()
.enumerate()
.map(|(offset, step)| {
let i = start + offset;
let (marker, style) = if i == last {
(
format!("{} ", spinner_frame(app.loading.tick)),
Style::default().fg(theme.accent),
)
} else {
("✓ ".to_string(), Style::default().fg(theme.dim))
};
Line::from(vec![
Span::styled(marker, style),
Span::styled(step.clone(), style),
])
})
.collect()
}
fn render_prompt(app: &App, f: &mut Frame, area: Rect) {
let theme = app.theme;
let focused = app.menu_focus == MenuFocus::Prompt;
let empty = app.prompt.is_empty();
let (text, fg) = if empty && !focused {
(
"Fix a bug, scaffold a service, explain a repo…".to_string(),
theme.dim,
)
} else if focused {
(format!("{}▏", app.prompt), theme.fg)
} else {
(app.prompt.clone(), theme.fg)
};
let count = if empty {
String::new()
} else {
format!(" {} ", app.prompt.chars().count())
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if focused {
theme.accent
} else {
theme.accent_dim
}))
.title(Span::styled(
" Prompt ",
Style::default()
.fg(if focused { theme.accent } else { theme.dim })
.add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(vec![
Span::styled(
format!(" {} ", app.harness_name()),
Style::default()
.fg(if focused { theme.accent } else { theme.fg })
.add_modifier(Modifier::BOLD),
),
Span::styled("shift+tab ", Style::default().fg(theme.dim)),
]))
.title_bottom(
Line::from(Span::styled(count, Style::default().fg(theme.dim))).right_aligned(),
);
let inner_w = area.width.saturating_sub(2).max(1) as usize;
let inner_h = area.height.saturating_sub(2).max(1) as usize;
let scroll_y = wrapped_lines(&text, inner_w).saturating_sub(inner_h) as u16;
f.render_widget(
Paragraph::new(text)
.block(block)
.style(Style::default().fg(fg))
.wrap(Wrap { trim: false })
.scroll((scroll_y, 0)),
area,
);
}
fn wrapped_lines(text: &str, width: usize) -> usize {
if width == 0 {
return 1;
}
let mut rows = 1usize;
let mut column = 0usize;
for word in text.split_inclusive(' ') {
let len = word.chars().count();
if column + len > width && column > 0 {
rows += 1;
column = 0;
}
if len > width {
rows += (len - 1) / width;
column = len % width;
} else {
column += len;
}
}
rows
}
struct CardBlock {
descriptions: Vec<Vec<String>>,
indent: usize,
}
impl CardBlock {
fn height(&self, cards: usize) -> u16 {
let text: usize = match self.descriptions.is_empty() {
true => cards,
false => self.descriptions.iter().map(|d| d.len().max(1)).sum(),
};
(text + cards) as u16
}
}
fn card_block(
cards: &[(&'static str, &'static str)],
width: usize,
descriptions: bool,
) -> CardBlock {
let names = || CardBlock {
descriptions: Vec::new(),
indent: width
.saturating_sub(
cards
.iter()
.map(|(label, _)| 2 + label.chars().count())
.max()
.unwrap_or(0),
)
.div_euclid(2),
};
let desc_w = width.saturating_sub(LABEL_W + CARD_GUTTER);
if !descriptions || desc_w < 24 {
return names();
}
let descriptions: Vec<Vec<String>> = cards
.iter()
.map(|(_, desc)| wrap_words(desc, desc_w))
.collect();
let content = LABEL_W
+ CARD_GUTTER
+ descriptions
.iter()
.flatten()
.map(|line| line.chars().count())
.max()
.unwrap_or(0);
CardBlock {
descriptions,
indent: width.saturating_sub(content) / 2,
}
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut line = String::new();
let mut column = 0usize;
for word in text.split_whitespace() {
let len = word.chars().count();
if column > 0 && column + 1 + len > width {
lines.push(std::mem::take(&mut line));
column = 0;
}
if column > 0 {
line.push(' ');
column += 1;
}
line.push_str(word);
column += len;
}
if !line.is_empty() {
lines.push(line);
}
lines
}
fn render_cards(app: &App, f: &mut Frame, area: Rect, block: &CardBlock, rects: &mut PaneRects) {
let theme = app.theme;
let cards = app.cards();
let indent = " ".repeat(block.indent);
rects.cards = Default::default();
let mut lines: Vec<Line> = Vec::new();
for (i, (label, _)) in cards.iter().enumerate() {
if let Some(slot) = rects.cards.get_mut(i) {
let height = block
.descriptions
.get(i)
.map(|d| d.len().max(1))
.unwrap_or(1);
*slot = PaneBox {
x: area.x,
y: area.y + lines.len() as u16,
w: area.width,
h: height as u16,
};
}
let on = app.menu_focus == MenuFocus::Cards && i == app.card;
let (fg, bg) = if on {
(theme.on_accent, theme.accent)
} else {
(theme.fg, Color::Reset)
};
let desc = block.descriptions.get(i);
let dim = Style::default().fg(if on { theme.fg } else { theme.dim });
lines.push(Line::from(vec![
Span::raw(indent.clone()),
Span::styled(
if on { "▌" } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(
match desc.is_some() {
true => format!(" {label:<LABEL_W$}"),
false => format!(" {label}"),
},
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
),
Span::styled(
match desc.and_then(|lines| lines.first()) {
Some(first) => format!(" {first}"),
None => String::new(),
},
dim,
),
]));
for line in desc.map(|d| &d[1.min(d.len())..]).unwrap_or(&[]) {
lines.push(Line::from(vec![
Span::raw(format!(
"{indent}{:width$}",
"",
width = LABEL_W + CARD_GUTTER
)),
Span::styled(line.clone(), dim),
]));
}
lines.push(Line::from(""));
}
f.render_widget(Paragraph::new(lines), area);
}
pub fn session_pane_size(
area: Option<ratatui::layout::Size>,
maximized: bool,
) -> Option<(u16, u16)> {
let area = area?;
if !maximized && area.width < 70 {
return None;
}
let rows = area.height.saturating_sub(3).saturating_sub(2).max(1);
let tree = if maximized { 0 } else { TREE_W };
let cols = area.width.saturating_sub(tree).saturating_sub(2).max(1);
Some((rows, cols))
}
fn render_manage(app: &App, f: &mut Frame, rects: &mut PaneRects) {
let theme = app.theme;
let area = f.area();
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ])
.split(area);
let rows = app.rows();
let header = Line::from(vec![
Span::styled(
" RAILWAY CLOUD-AGENTS ",
Style::default()
.fg(theme.on_accent)
.bg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
if app.status.is_empty() {
String::new()
} else {
format!(" · {}", app.status)
},
Style::default().fg(theme.dim),
),
]);
f.render_widget(Paragraph::new(header), chunks[0]);
let full = app.maximized && app.active_session().is_some();
let two_pane = !full && chunks[2].width >= 70;
let panes = if two_pane {
Layout::horizontal([Constraint::Length(TREE_W), Constraint::Min(20)]).split(chunks[2])
} else {
Layout::horizontal([Constraint::Min(0)]).split(chunks[2])
};
if full {
let pane = panes[0];
rects.session = interior(pane);
rects.session_outer = whole(pane);
rects.tree = PaneBox::default();
rects.tree_outer = PaneBox::default();
if let Some(session) = app.active_session() {
render_session(app, session, f, pane);
}
render_manage_footer(app, f, chunks[3], rects);
return;
}
let tree_focused = app.focus == ManageFocus::Tree;
let items: Vec<ListItem> = rows
.iter()
.map(|r| ListItem::new(tree_line(theme, r)))
.collect();
let mut state = ListState::default();
state.select(if rows.is_empty() {
None
} else {
Some(app.cursor)
});
f.render_stateful_widget(
List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if tree_focused {
theme.accent
} else {
theme.accent_dim
}))
.title(Span::styled(" projects ", Style::default().fg(theme.dim))),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(theme.selection),
),
panes[0],
&mut state,
);
rects.tree = interior(panes[0]);
rects.tree_outer = whole(panes[0]);
if two_pane {
rects.session = interior(panes[1]);
rects.session_outer = whole(panes[1]);
let show_session = app.focus == ManageFocus::Session
|| matches!(
app.selected_row().map(|row| row.kind),
Some(RowKind::Session(..))
);
if app.loading.active {
render_loading(app, f, panes[1]);
} else {
match app.active_session().filter(|_| show_session) {
Some(session) => render_session(app, session, f, panes[1]),
None => f.render_widget(
Paragraph::new(detail_lines(app)).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent_dim))
.title(Span::styled(" agent ", Style::default().fg(theme.dim))),
),
panes[1],
),
}
}
}
render_manage_footer(app, f, chunks[3], rects);
}
fn render_manage_footer(app: &App, f: &mut Frame, area: Rect, rects: &PaneRects) {
let theme = app.theme;
if let Some(confirm) = app.confirm.as_ref() {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" confirm ",
Style::default()
.fg(theme.on_accent)
.bg(theme.pending)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", confirm.question()),
Style::default().fg(theme.fg),
),
])),
area,
);
return;
}
if let Some(selection) = app.selection.filter(|s| !s.is_empty()) {
let bounds = match selection.pane {
ManageFocus::Tree => rects.tree,
ManageFocus::Session => rects.session,
};
let spans = selection.spans(bounds);
let buffer = f.buffer_mut();
for (y, x0, x1) in spans {
for x in x0..=x1 {
buffer[(x, y)].set_style(
Style::default()
.bg(theme.selection)
.add_modifier(Modifier::BOLD),
);
}
}
}
let sleeping = app
.selected_agent_status()
.is_some_and(|status| status != "running");
let hint: Vec<(&str, &str)> = if app.maximized {
vec![
("⌥f", "restore the tree"),
("shift+esc / ^]", "stop typing"),
]
} else if app.focus == ManageFocus::Session {
let mut keys = vec![("shift+esc / ^]", "stop typing"), ("⌥f", "maximize")];
if app.active_session().is_some_and(|s| s.wants_mouse()) {
keys.push(("shift+drag", "select"));
}
keys
} else {
match app.selected_row().map(|r| r.kind) {
Some(RowKind::Session(..)) => vec![
("enter", "connect"),
("⌥f", "maximize"),
("shift+enter", "full screen"),
("c", "copy ssh"),
("x", "end session"),
if sleeping {
("w", "wake")
} else {
("s", "sleep")
},
("d", "delete agent"),
],
Some(RowKind::Agent(..)) => vec![
("enter", "connect"),
("n", "new session"),
if sleeping {
("w", "wake")
} else {
("s", "sleep")
},
("d", "delete agent"),
],
_ => vec![("enter", "open"), ("n", "new agent")],
}
};
let mut hint = hint;
if app.sessions.len() > 1 {
hint.push(("⌥]", "next session"));
}
let spans = chord_spans(theme, &hint);
f.render_widget(Paragraph::new(Line::from(spans)), area);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" ? ",
Style::default()
.fg(theme.on_accent)
.bg(theme.accent_dim)
.add_modifier(Modifier::BOLD),
),
Span::styled(" keys ", Style::default().fg(theme.dim)),
]))
.alignment(Alignment::Right),
area,
);
if app.keys_open {
render_keys(app, f);
}
}
struct PanelRow {
label: String,
tag: String,
detail: String,
}
struct Panel<'a> {
title: &'a str,
heading: &'a str,
position: Option<(usize, usize)>,
rows: &'a [PanelRow],
cursor: usize,
footer: Line<'static>,
}
fn render_panel(f: &mut Frame, theme: &Theme, area: Rect, panel: Panel) {
let body_h = panel
.rows
.iter()
.map(|row| if row.detail.is_empty() { 1 } else { 2 })
.sum::<usize>() as u16;
let dots_h = u16::from(panel.position.is_some());
let width = 66.min(area.width.saturating_sub(4));
let height = (body_h + dots_h + 7).min(area.height.saturating_sub(2));
let outer = centered(width, height, area);
f.render_widget(Clear, outer);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
format!(" {} ", panel.title),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(outer);
f.render_widget(block, outer);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(dots_h),
Constraint::Length(1), Constraint::Length(body_h),
Constraint::Min(0),
Constraint::Length(1), ])
.split(inner);
f.render_widget(
Paragraph::new(panel.heading.to_string())
.alignment(Alignment::Center)
.style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
rows[0],
);
if let Some((index, total)) = panel.position {
let dots: Vec<Span> = (0..total)
.map(|i| {
Span::styled(
if i == index { "● " } else { "○ " },
Style::default().fg(if i == index {
theme.accent
} else {
theme.accent_dim
}),
)
})
.collect();
f.render_widget(
Paragraph::new(Line::from(dots)).alignment(Alignment::Center),
rows[1],
);
}
let mut lines: Vec<Line> = Vec::with_capacity(panel.rows.len() * 2);
for (i, row) in panel.rows.iter().enumerate() {
let on = i == panel.cursor;
let mut spans = vec![
Span::styled(
if on { " ▌ " } else { " " },
Style::default().fg(theme.accent),
),
Span::styled(
row.label.clone(),
if on {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg)
},
),
];
if !row.tag.is_empty() {
spans.push(Span::styled(
format!(" {}", row.tag),
Style::default().fg(theme.dim),
));
}
lines.push(Line::from(spans));
if !row.detail.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", row.detail),
Style::default().fg(theme.dim),
)));
}
}
f.render_widget(Paragraph::new(lines), rows[3]);
f.render_widget(
Paragraph::new(panel.footer).alignment(Alignment::Center),
rows[5],
);
}
fn render_wizard(app: &App, f: &mut Frame) {
let Some(wizard) = app.wizard.as_ref() else {
return;
};
let theme = app.theme;
let rows: Vec<PanelRow> = wizard
.options()
.into_iter()
.map(|(label, detail)| PanelRow {
label,
tag: String::new(),
detail,
})
.collect();
let footer = if let Some(busy) = wizard.busy.as_deref() {
Line::from(vec![
Span::styled(
format!("{} ", spinner_frame(app.loading.tick)),
Style::default().fg(theme.accent),
),
Span::styled(busy.to_string(), Style::default().fg(theme.fg)),
])
} else if let Some(error) = wizard.error.as_deref() {
Line::from(Span::styled(
format!(" {error}"),
Style::default().fg(theme.pending),
))
} else {
Line::from(chord_spans(
theme,
&[("↑↓", "choose"), ("enter", "next"), ("esc", "back")],
))
};
render_panel(
f,
theme,
f.area(),
Panel {
title: "setup",
heading: wizard.title(),
position: wizard.position(),
rows: &rows,
cursor: wizard.cursor,
footer,
},
);
}
fn render_agent_pick(app: &App, f: &mut Frame) {
let Some(picker) = app.agent_pick.as_ref() else {
return;
};
let theme = app.theme;
let rows: Vec<PanelRow> = picker
.rows()
.into_iter()
.map(|(label, tag)| PanelRow {
label,
tag,
detail: String::new(),
})
.collect();
let footer = Line::from(chord_spans(
theme,
&[
("↑↓", "choose"),
("enter", "new session"),
("esc", "cancel"),
],
));
render_panel(
f,
theme,
f.area(),
Panel {
title: "new session",
heading: "Which cloud agent?",
position: None,
rows: &rows,
cursor: picker.cursor,
footer,
},
);
}
fn render_target_pick(app: &App, f: &mut Frame) {
let Some(picker) = app.target_pick.as_ref() else {
return;
};
let theme = app.theme;
let rows: Vec<PanelRow> = picker
.rows(app.default_project.as_deref())
.into_iter()
.map(|(label, tag)| PanelRow {
label,
tag,
detail: String::new(),
})
.collect();
let footer = if rows.is_empty() {
Line::from(Span::styled(
"No projects to pick from",
Style::default().fg(theme.dim),
))
} else {
Line::from(chord_spans(
theme,
&[("↑↓", "choose"), ("enter", "set target"), ("esc", "cancel")],
))
};
render_panel(
f,
theme,
f.area(),
Panel {
title: "target",
heading: "Where should Cloud Agents run?",
position: None,
rows: &rows,
cursor: picker.cursor,
footer,
},
);
}
fn render_keys(app: &App, f: &mut Frame) {
let theme = app.theme;
let area = f.area();
let chord_w = KEY_HELP
.iter()
.flat_map(|(_, keys)| keys.iter().map(|(chord, _)| chord.chars().count()))
.max()
.unwrap_or(8);
let mut lines: Vec<Line> = Vec::new();
for (group, keys) in KEY_HELP {
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
format!(" {group}"),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)));
for (chord, what) in *keys {
lines.push(Line::from(vec![
Span::styled(
format!(" {chord:>chord_w$} "),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::styled((*what).to_string(), Style::default().fg(theme.dim)),
]));
}
}
let width = 52.min(area.width.saturating_sub(4));
let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(2));
let panel = centered(width, height, area);
f.render_widget(Clear, panel);
f.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.title(Span::styled(
" keys ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(Span::styled(
" any key closes ",
Style::default().fg(theme.dim),
))),
),
panel,
);
}
fn render_session(app: &App, session: &super::session::Session, f: &mut Frame, area: Rect) {
let theme = app.theme;
let focused = app.focus == ManageFocus::Session;
let title = format!(" {} · {} ", session.agent_name, session.durable_name);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if focused {
theme.accent
} else {
theme.accent_dim
}))
.title(Span::styled(
title,
Style::default()
.fg(if focused { theme.accent } else { theme.dim })
.add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(Span::styled(
if session.ended() {
" session ended "
} else if session.scrolled_back() {
" scrolled back · type to return "
} else if !session.scrollable() {
" no scrollback here "
} else if focused {
""
} else {
" click or enter to type "
},
Style::default().fg(theme.dim),
)));
let inner = block.inner(area);
f.render_widget(block, area);
let Some(lines) = session.with_screen(|screen| screen_lines(screen, focused)) else {
return;
};
f.render_widget(Paragraph::new(lines), inner);
}
fn screen_lines(screen: &vt100::Screen, focused: bool) -> Vec<Line<'static>> {
let (rows, cols) = screen.size();
let (cursor_row, cursor_col) = screen.cursor_position();
let mut out = Vec::with_capacity(rows as usize);
for row in 0..rows {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut run = String::new();
let mut run_style: Option<Style> = None;
for col in 0..cols {
let (text, mut style) = match screen.cell(row, col) {
Some(cell) => (
{
let c = cell.contents();
if c.is_empty() { " ".to_string() } else { c }
},
cell_style(cell),
),
None => (" ".to_string(), Style::default()),
};
if focused && !screen.hide_cursor() && row == cursor_row && col == cursor_col {
style = style.add_modifier(Modifier::REVERSED);
}
match run_style {
Some(current) if current == style => run.push_str(&text),
Some(current) => {
spans.push(Span::styled(std::mem::take(&mut run), current));
run.push_str(&text);
run_style = Some(style);
}
None => {
run.push_str(&text);
run_style = Some(style);
}
}
}
if let Some(style) = run_style {
spans.push(Span::styled(run, style));
}
out.push(Line::from(spans));
}
out
}
fn cell_style(cell: &vt100::Cell) -> Style {
let mut style = Style::default();
if let Some(fg) = convert_color(cell.fgcolor()) {
style = style.fg(fg);
}
if let Some(bg) = convert_color(cell.bgcolor()) {
style = style.bg(bg);
}
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
style
}
fn convert_color(color: vt100::Color) -> Option<Color> {
match color {
vt100::Color::Default => None,
vt100::Color::Idx(i) => Some(Color::Indexed(i)),
vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
}
}
fn session_state(app: &App, name: &str, running: bool) -> &'static str {
if !running {
"exited"
} else if app.sessions.iter().any(|pane| pane.durable_name == name) {
"connected"
} else {
"running"
}
}
fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let kept: String = text.chars().take(max.saturating_sub(1)).collect();
format!("{}…", kept.trim_end())
}
fn status_color(theme: &Theme, status: &str) -> Color {
match status {
"running" => theme.running,
"sleeping" | "stopped" => theme.sleeping,
_ => theme.pending,
}
}
fn status_glyph(status: &str) -> &'static str {
match status {
"running" => "●",
"sleeping" | "stopped" => "○",
_ => "◌",
}
}
fn tree_line(theme: &Theme, row: &Row) -> Line<'static> {
let indent = " ".repeat(row.depth);
let mut spans = vec![Span::raw(indent)];
match (&row.kind, row.expanded) {
(RowKind::Agent(..), _) => {
let status = row.status.clone().unwrap_or_default();
spans.push(Span::styled(
format!("{} ", status_glyph(&status)),
Style::default().fg(status_color(theme, &status)),
));
spans.push(Span::styled(
row.label.clone(),
Style::default().fg(theme.fg),
));
}
(RowKind::Session(..), _) => {
let connected = row.status.is_some();
spans.push(Span::styled(
if connected { "● " } else { "↳ " },
Style::default().fg(if connected { theme.running } else { theme.dim }),
));
spans.push(Span::styled(
row.label.clone(),
Style::default().fg(theme.fg),
));
}
(RowKind::Separator, _) => spans.push(Span::styled(
"─".repeat(TREE_W.saturating_sub(4) as usize),
Style::default().fg(theme.accent_dim),
)),
(RowKind::Note(..), _) => spans.push(Span::styled(
row.label.clone(),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
)),
(_, Some(expanded)) => {
spans.push(Span::styled(
if expanded { "▾ " } else { "▸ " },
Style::default().fg(if row.dimmed {
theme.accent_dim
} else {
theme.accent
}),
));
let style = match row.kind {
_ if row.dimmed => Style::default().fg(theme.dim),
RowKind::Workspace(_) => Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
_ => Style::default().fg(theme.fg),
};
spans.push(Span::styled(row.label.clone(), style));
}
_ => spans.push(Span::raw(row.label.clone())),
}
if !row.note.is_empty() && !matches!(row.kind, RowKind::Agent(..)) {
spans.push(Span::styled(
format!(" {}", row.note),
Style::default().fg(theme.dim),
));
}
Line::from(spans)
}
fn detail_lines(app: &App) -> Vec<Line<'static>> {
let theme = app.theme;
let kv = |k: &str, v: String| {
Line::from(vec![
Span::styled(format!(" {k:<9}"), Style::default().fg(theme.dim)),
Span::styled(v, Style::default().fg(theme.fg)),
])
};
let Some(row) = app.selected_row() else {
return vec![Line::from(Span::styled(
" nothing selected",
Style::default().fg(theme.dim),
))];
};
match row.kind {
RowKind::Agent(w, p, e, a) => {
let proj = &app.tree[w].projects[p];
let env = &proj.envs[e];
let name = row.label.clone();
let status = row.status.clone().unwrap_or_default();
let agent = match &env.agents {
Load::Loaded(list) => list.get(a),
_ => None,
};
let mut lines = vec![
Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(vec![
Span::styled(
format!(" {} {}", status_glyph(&status), status),
Style::default().fg(status_color(theme, &status)),
),
Span::styled(
format!(" · {}/{}", proj.name, env.name),
Style::default().fg(theme.dim),
),
]),
Line::from(""),
];
match agent.map(|agent| &agent.sessions) {
Some(LoadSessions::Loaded(sessions)) => {
let live: Vec<_> = sessions
.iter()
.filter(|session| session.is_interesting())
.collect();
if live.is_empty() {
lines.push(Line::from(Span::styled(
" no sessions running",
Style::default().fg(theme.dim),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" n starts one",
Style::default().fg(theme.dim),
)));
}
for session in live {
let connected = app
.sessions
.iter()
.find(|pane| pane.durable_name == session.name);
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", if connected.is_some() { "▌" } else { " " }),
Style::default().fg(theme.accent),
),
Span::styled(
session.name.clone(),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", session_state(app, &session.name, session.running)),
Style::default().fg(theme.dim),
),
]));
let message = match connected.and_then(|pane| pane.last_line()) {
Some(line) => (truncate(&line, 60), theme.fg),
None => ("not connected — enter to attach".into(), theme.dim),
};
lines.push(Line::from(Span::styled(
format!(" {}", message.0),
Style::default().fg(message.1),
)));
lines.push(Line::from(""));
}
}
Some(LoadSessions::Loading) => lines.push(Line::from(Span::styled(
" loading sessions…",
Style::default().fg(theme.dim),
))),
Some(LoadSessions::Failed(err)) => lines.push(Line::from(Span::styled(
format!(" couldn't load sessions: {err}"),
Style::default().fg(theme.pending),
))),
_ => lines.push(Line::from(Span::styled(
" → to load its sessions",
Style::default().fg(theme.dim),
))),
}
lines
}
RowKind::Environment(w, p, e) => {
let proj = &app.tree[w].projects[p];
let env = &proj.envs[e];
let count = match &env.agents {
super::app::Load::Loaded(l) => format!("{}", l.len()),
super::app::Load::Loading => "loading…".into(),
super::app::Load::Failed(_) => "unknown".into(),
super::app::Load::NotLoaded => "→ to load".into(),
};
vec![
Line::from(Span::styled(
format!(" {}/{}", proj.name, env.name),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
kv("agents", count),
Line::from(""),
Line::from(Span::styled(
" n creates one here · t targets it",
Style::default().fg(theme.dim),
)),
]
}
RowKind::Project(w, p) => {
let proj = &app.tree[w].projects[p];
vec![
Line::from(Span::styled(
format!(" {}", proj.name),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
kv("envs", proj.envs.len().to_string()),
kv("id", proj.id.clone()),
]
}
RowKind::Workspace(w) => {
let ws = &app.tree[w];
vec![
Line::from(Span::styled(
format!(" {}", ws.name),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
kv("projects", ws.projects.len().to_string()),
]
}
RowKind::Session(w, p, e, a, i) => {
let mut lines = vec![Line::from(Span::styled(
format!(" {}", row.label),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))];
if let Load::Loaded(agents) = &app.tree[w].projects[p].envs[e].agents
&& let Some(agent) = agents.get(a)
&& let LoadSessions::Loaded(sessions) = &agent.sessions
&& let Some(session) = sessions.get(i)
{
lines.push(Line::from(""));
lines.push(kv(
"state",
session_state(app, &session.name, session.running).to_string(),
));
lines.push(kv("kind", session.kind.to_lowercase()));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", session.command_summary()),
Style::default().fg(theme.fg),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" enter reattaches in the pane",
Style::default().fg(theme.dim),
)));
lines
}
RowKind::Separator | RowKind::Note(..) => vec![Line::from("")],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::cloud_agent::tui::app::{
Agent, EnvNode, Load, LoadSessions, ProjectNode, Screen, Target, WorkspaceNode,
};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
pub(super) fn app_with_tree() -> App {
let tree = vec![WorkspaceNode {
name: "Railway".into(),
expanded: true,
projects: vec![ProjectNode {
id: "proj_1".into(),
name: "devtools".into(),
expanded: true,
envs: vec![EnvNode {
id: "env_prod".into(),
name: "production".into(),
expanded: true,
agents: Load::Loaded(vec![Agent {
id: "ca_1".into(),
name: "nimble-otter".into(),
status: "running".into(),
sessions: LoadSessions::NotLoaded,
expanded: false,
}]),
}],
}],
}];
App::new(
tree,
Some(Target {
project_id: "proj_1".into(),
project_name: "devtools".into(),
environment_id: "env_prod".into(),
environment_name: "production".into(),
}),
Some("claude"),
None,
None,
true,
)
}
pub(super) fn draw(app: &App, w: u16, h: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|f| {
render_with_layout(app, f);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn banner_uses_no_box_drawing_glyphs() {
let stray: Vec<char> = BANNER
.chars()
.filter(|c| !matches!(c, '█' | ' ' | '\n'))
.collect();
assert!(
stray.is_empty(),
"non-block glyphs in the banner: {stray:?}"
);
assert_eq!(BANNER.lines().count(), BANNER_H as usize);
let widths: std::collections::HashSet<usize> = banner_lines(Theme::default_theme())
.iter()
.map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum())
.collect();
assert_eq!(
widths,
std::collections::HashSet::from([BANNER_W as usize]),
"banner rows must all be {BANNER_W} wide, got {widths:?}"
);
assert!(
BANNER
.lines()
.all(|l| l.chars().count() <= BANNER_W as usize),
"a banner row is wider than BANNER_W"
);
for line in BANNER.lines() {
let padded = format!("{line:<width$}", width = BANNER_W as usize);
let y: String = padded.chars().skip(47).collect();
let mirrored: String = y.chars().rev().collect();
assert_eq!(y, mirrored, "the Y must be symmetric: {y:?}");
}
}
#[test]
fn the_menu_footer_uses_chord_badges() {
let app = app_with_tree();
let out = draw(&app, 100, 40);
let footer = out
.lines()
.rfind(|l| l.contains("launch"))
.expect("the menu footer");
assert!(footer.contains("enter"), "{footer}");
assert!(footer.contains("target"), "{footer}");
assert!(footer.contains("theme"), "{footer}");
assert!(footer.contains("setup"), "{footer}");
assert!(!footer.contains("menu"), "the arrow hint is gone: {footer}");
assert!(!footer.contains(" · "), "{footer}");
}
#[test]
fn the_menu_cards_carry_no_key_badges() {
let app = app_with_tree();
let out = draw(&app, 100, 40);
let card = out
.lines()
.find(|l| l.contains("New Session"))
.expect("the New Session card");
assert!(!card.contains(" n "), "no key badge: {card}");
let card = out
.lines()
.find(|l| l.contains("Manage Cloud Agents"))
.expect("the Manage card");
assert!(!card.contains(" m "), "no key badge: {card}");
}
const CARD_LINES: &[&str] = &[
"New Session",
"New Cloud Agent",
"Manage Cloud Agents",
"Setup",
];
#[test]
fn the_menu_cards_are_centred_as_a_block() {
let mut app = app_with_tree();
app.configured = false;
let width = 100u16;
let out = draw(&app, width, 40);
let starts: Vec<usize> = out
.lines()
.filter(|l| CARD_LINES.iter().any(|card| l.contains(card)))
.map(|l| l.len() - l.trim_start().len())
.collect();
assert_eq!(starts.len(), 4, "three cards plus setup");
assert!(
starts.iter().all(|s| *s == starts[0]),
"one left edge, not three: {starts:?}"
);
let right_edge = out
.lines()
.filter(|l| CARD_LINES.iter().any(|card| l.contains(card)))
.map(|l| l.trim_end().chars().count())
.max()
.expect("a card");
let middle = (starts[0] + right_edge) / 2;
assert!(
middle.abs_diff(width as usize / 2) <= 2,
"the block should be centred: {starts:?}..{right_edge} on {width}"
);
}
#[test]
fn setup_is_a_card_only_on_a_first_run() {
let mut app = app_with_tree();
let out = draw(&app, 100, 40);
assert!(!out.contains("Default agent, skills"), "{out}");
assert!(out.contains("setup"), "the chord is still offered:\n{out}");
app.configured = false;
let out = draw(&app, 100, 40);
assert!(out.contains("Default agent, skills"), "{out}");
}
#[test]
fn the_target_sits_above_the_shortcuts_not_in_the_prompt() {
let app = app_with_tree();
let out = draw(&app, 100, 40);
let lines: Vec<&str> = out.lines().collect();
let prompt_bottom = lines
.iter()
.position(|l| l.contains("claude") && l.contains("shift+tab"))
.expect("the prompt box footer");
assert!(
!lines[prompt_bottom].contains("devtools"),
"the target left the prompt box: {}",
lines[prompt_bottom]
);
let target = lines
.iter()
.position(|l| l.contains("Target Project"))
.expect("the target indicator");
assert!(
lines[target].contains("devtools (production)"),
"{}",
lines[target]
);
let footer = lines
.iter()
.position(|l| l.contains("launch"))
.expect("the footer");
assert!(target < footer, "the target sits above the shortcuts");
}
#[test]
fn no_target_says_not_set() {
let mut app = app_with_tree();
app.target = None;
let out = draw(&app, 100, 40);
assert!(out.contains("Target Project not set"), "{out}");
}
#[test]
fn the_recorded_card_boxes_match_the_drawn_rows() {
use crate::commands::cloud_agent::tui::app::PaneRects;
let mut app = app_with_tree();
app.configured = false; let mut terminal = Terminal::new(TestBackend::new(100, 44)).unwrap();
let mut rects = PaneRects::default();
terminal
.draw(|f| {
let (r, _) = render_with_layout(&app, f);
rects = r;
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let out = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let lines: Vec<&str> = out.lines().collect();
for (i, (label, _)) in app.cards().iter().enumerate() {
let drawn = lines
.iter()
.position(|l| l.contains(label))
.unwrap_or_else(|| panic!("{label} was not drawn"));
let box_ = rects.cards[i];
assert_eq!(
box_.y as usize, drawn,
"{label}: recorded at {}, drawn at {drawn}",
box_.y
);
assert!(box_.h >= 1, "{label} has no clickable height");
let wrapped = out
.lines()
.nth(drawn + 1)
.is_some_and(|l| !l.trim().is_empty() && !CARD_LINES.iter().any(|c| l.contains(c)));
assert_eq!(
box_.h >= 2,
wrapped,
"{label}: height {} does not match its wrapping",
box_.h
);
}
let prompt = lines
.iter()
.position(|l| l.contains("╭ Prompt"))
.expect("the prompt box");
assert_eq!(rects.prompt.y as usize, prompt);
}
#[test]
fn a_toast_floats_in_the_bottom_corner() {
use crate::commands::cloud_agent::tui::session::Session;
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.attach_session(
Session::for_test("ca_1", "nimble-otter").unwrap(),
"ca_1".into(),
);
app.toast("Copied 3 lines");
let out = draw(&app, 92, 20);
let lines: Vec<&str> = out.lines().collect();
let row = lines
.iter()
.position(|l| l.contains("Copied 3 lines"))
.expect("the toast");
assert!(out.contains("✓"), "{out}");
assert!(row > lines.len() / 2, "in the bottom half: {row}");
let start = lines[row]
.chars()
.collect::<Vec<_>>()
.windows(6)
.position(|w| w.iter().collect::<String>() == "Copied")
.expect("the toast text");
assert!(start > 92 / 2, "on the right: {start}");
assert!(
lines[lines.len() - 1].contains("keys"),
"the key strip is untouched: {}",
lines[lines.len() - 1]
);
assert!(
lines[row + 1].contains("╰") && !lines[row + 1].contains("Copied"),
"the toast is closed above the pane border: {}",
lines[row + 1]
);
}
#[test]
fn a_failed_copy_is_marked_as_one() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.toast_error("Couldn't copy: no clipboard");
let out = draw(&app, 92, 20);
assert!(out.contains("✕"), "{out}");
assert!(!out.contains("✓"), "{out}");
}
#[test]
fn an_expired_toast_is_not_drawn() {
use crate::commands::cloud_agent::tui::app::{TOAST_LIFETIME, Toast};
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.toast = Some(Toast {
text: "Copied 3 lines".into(),
at: std::time::Instant::now() - TOAST_LIFETIME,
ok: true,
});
let out = draw(&app, 92, 20);
assert!(!out.contains("Copied 3 lines"), "{out}");
}
#[test]
fn a_focused_pane_does_not_repeat_the_escape_chord() {
use crate::commands::cloud_agent::tui::session::Session;
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.attach_session(
Session::for_test("ca_1", "nimble-otter").unwrap(),
"ca_1".into(),
);
let out = draw(&app, 92, 20);
let border = out
.lines()
.find(|l| l.starts_with("╰"))
.expect("the pane's bottom border");
assert!(!border.contains("to leave"), "{border}");
assert!(
out.lines().next_back().unwrap().contains("stop typing"),
"the key strip still has it:\n{out}"
);
}
#[test]
fn a_maximized_session_takes_the_whole_screen() {
use crate::commands::cloud_agent::tui::session::Session;
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.attach_session(
Session::for_test("ca_1", "nimble-otter").unwrap(),
"ca_1".into(),
);
let before = draw(&app, 100, 30);
assert!(before.contains("projects"), "the tree is there first");
app.maximized = true;
let out = draw(&app, 100, 30);
assert!(!out.contains(" projects "), "the tree is gone:\n{out}");
assert!(!out.contains("devtools"), "no tree rows:\n{out}");
assert!(out.contains("restore the tree"), "the way back:\n{out}");
let pane = out
.lines()
.find(|l| l.contains("╭"))
.expect("the session pane");
assert_eq!(
pane.chars().position(|c| c == '╭'),
Some(0),
"the pane starts at the left edge: {pane}"
);
}
#[test]
fn the_emulator_follows_the_maximized_pane() {
let size = Some(ratatui::layout::Size {
width: 100,
height: 30,
});
let (_, split) = session_pane_size(size, false).unwrap();
let (_, full) = session_pane_size(size, true).unwrap();
assert_eq!(split, 100 - TREE_W - 2);
assert_eq!(full, 98);
let narrow = Some(ratatui::layout::Size {
width: 50,
height: 20,
});
assert!(session_pane_size(narrow, false).is_none());
assert!(session_pane_size(narrow, true).is_some());
}
#[test]
fn the_agent_picker_lists_the_agents_with_their_status() {
use crate::commands::cloud_agent::tui::app::AgentPicker;
let mut app = app_with_tree();
app.agent_pick = Some(AgentPicker {
options: vec![
("ca_1".into(), "nimble-otter".into(), "running".into()),
("ca_2".into(), "brisk-heron".into(), "sleeping".into()),
],
cursor: 0,
});
app.screen = Screen::AgentPick;
let out = draw(&app, 100, 34);
assert!(out.contains("Which cloud agent?"), "{out}");
assert!(out.contains("nimble-otter"), "{out}");
assert!(out.contains("sleeping"), "the status rides along: {out}");
assert!(out.contains("new session"), "{out}");
}
#[test]
fn card_descriptions_wrap_inside_the_prompt_box() {
let app = app_with_tree();
for width in [120u16, 100, 90, 80, 60] {
let out = draw(&app, width, 44);
let lines: Vec<&str> = out.lines().collect();
let box_left = lines
.iter()
.find(|l| l.contains("╭ Prompt"))
.map(|l| l.chars().position(|c| c == '╭').unwrap())
.expect("the prompt box");
let box_right = lines
.iter()
.find(|l| l.contains("╭ Prompt"))
.map(|l| l.chars().position(|c| c == '╮').unwrap())
.expect("the prompt box");
for line in lines
.iter()
.filter(|l| CARD_LINES.iter().any(|card| l.contains(card)) || l.contains("project"))
{
let start = line.len() - line.trim_start().len();
let end = line.trim_end().chars().count();
assert!(
start >= box_left && end <= box_right + 1,
"at {width}: {start}..{end} outside {box_left}..{box_right}\n{line}"
);
}
let text: String = lines
.join(" ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
text.contains("Create a new session on a Cloud Agent in your default project"),
"at {width}: the description was lost\n{out}"
);
}
}
#[test]
fn very_narrow_cards_keep_their_names_and_lose_the_descriptions() {
let app = app_with_tree();
let out = draw(&app, 46, 40);
assert!(out.contains("New Session"), "{out}");
assert!(out.contains("Manage Cloud Agents"), "{out}");
assert!(!out.contains("Create a new"), "{out}");
assert!(
out.lines().all(|l| l.trim_end().chars().count() <= 46),
"nothing runs off the edge:\n{out}"
);
}
#[test]
fn wrap_words_breaks_on_spaces_and_keeps_every_word() {
let wrapped = wrap_words("Create a new Cloud Agent in your default project", 20);
assert!(wrapped.len() > 1, "{wrapped:?}");
assert!(
wrapped.iter().all(|l| l.chars().count() <= 20),
"{wrapped:?}"
);
assert_eq!(
wrapped.join(" "),
"Create a new Cloud Agent in your default project"
);
assert_eq!(wrap_words("", 20), Vec::<String>::new());
}
#[test]
fn menu_degrades_to_a_wordmark_when_small() {
let app = app_with_tree();
let out = draw(&app, 50, 20);
assert!(!out.contains("█"), "banner should be dropped:\n{out}");
assert!(out.contains("RAILWAY CLOUD-AGENTS"));
assert!(out.contains("Prompt"));
}
#[test]
fn manage_renders_the_tree_and_the_detail_pane() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "nimble-otter")
.unwrap();
let out = draw(&app, 100, 30);
assert!(out.contains("Railway"));
assert!(out.contains("devtools"));
assert!(out.contains("production"));
assert!(out.contains("nimble-otter"));
assert!(
out.contains("running"),
"status belongs in the detail pane:\n{out}"
);
assert!(
out.contains("connect"),
"the footer names the action:\n{out}"
);
}
#[test]
fn manage_drops_the_detail_pane_when_narrow() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
let out = draw(&app, 60, 20);
assert!(out.contains("nimble-otter"));
assert!(
!out.contains("╭ agent "),
"detail pane should be gone:\n{out}"
);
}
#[test]
fn the_target_picker_is_a_card_over_the_menu() {
let mut app = app_with_tree();
app.start_target_pick();
let out = draw(&app, 100, 34);
assert!(out.contains("target"), "{out}");
assert!(out.contains("Where should Cloud Agents run?"), "{out}");
assert!(out.contains("devtools (production)"), "{out}");
assert!(out.contains("set target"), "{out}");
assert!(
!out.contains("╭ agents"),
"the management tree must not be behind it:\n{out}"
);
}
#[test]
fn manage_shows_an_open_session_in_the_pane() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.attach_session(
crate::commands::cloud_agent::tui::session::Session::for_test("ca_1", "nimble-otter")
.unwrap(),
"ca_1".into(),
);
let out = draw(&app, 100, 30);
assert!(out.contains("nimble-otter"), "{out}");
assert!(
out.contains("test"),
"the durable name in the title:\n{out}"
);
assert!(!out.contains("sessions ·"), "{out}");
}
#[test]
fn the_loading_state_renders_in_the_session_pane() {
let mut app = app_with_tree();
app.start_loading(&crate::commands::cloud_agent::tui::LaunchRequest {
project_id: "proj_1".into(),
environment_id: "env_prod".into(),
agent_id: None,
session_name: None,
force_new: false,
new_session: false,
harness: "claude".into(),
prompt: Some("fix the failing tests".into()),
label: "devtools/production".into(),
});
app.loading_step("Creating a cloud agent".into());
let out = draw(&app, 100, 30);
assert!(out.contains("starting"), "pane title:\n{out}");
assert!(out.contains("fix the failing tests"), "the task:\n{out}");
assert!(out.contains("Creating a cloud agent"), "steps:\n{out}");
assert!(out.contains("devtools"), "tree stays visible:\n{out}");
let line: Vec<char> = out
.lines()
.find(|l| l.contains("Creating a cloud agent"))
.unwrap()
.chars()
.collect();
let needle: Vec<char> = "Creating a cloud agent".chars().collect();
let text_start = line
.windows(needle.len())
.position(|w| w == needle.as_slice())
.expect("the step text");
let text_end = text_start + needle.len() - 1;
let block_start = text_start.saturating_sub(2);
let left_border = (0..block_start)
.rev()
.find(|i| line[*i] == '│')
.expect("a border to the left");
let right_border = (text_end + 1..line.len())
.find(|i| line[*i] == '│')
.expect("a border to the right");
let gap_left = block_start - left_border - 1;
let gap_right = right_border - text_end - 1;
assert!(gap_left > 2, "hugging the left border: {gap_left}");
assert!(
gap_left.abs_diff(gap_right) <= 4,
"left {gap_left} and right {gap_right} gaps should be close:\n{out}"
);
}
#[test]
fn the_footer_shows_the_actions_for_the_selected_row() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "nimble-otter")
.unwrap();
let out = draw(&app, 120, 30);
let footer = out.lines().last().unwrap();
assert!(footer.contains("connect"), "{footer}");
assert!(footer.contains("new session"), "{footer}");
assert!(footer.contains("delete agent"), "{footer}");
assert!(footer.contains("sleep"), "{footer}");
assert!(!footer.contains("wake"), "{footer}");
assert!(footer.trim_end().ends_with("keys"), "{footer}");
assert!(
footer.find("keys").unwrap() > footer.find("connect").unwrap(),
"help should be right of the actions:\n{footer}"
);
}
#[test]
fn the_footer_offers_wake_for_a_sleeping_agent() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
if let Load::Loaded(agents) = &mut app.tree[0].projects[0].envs[0].agents {
agents[0].status = "sleeping".into();
}
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "nimble-otter")
.unwrap();
let footer = draw(&app, 120, 30).lines().last().unwrap().to_string();
assert!(footer.contains("wake"), "{footer}");
assert!(!footer.contains("sleep"), "{footer}");
}
#[test]
fn the_footer_is_shorter_on_a_project() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "devtools")
.unwrap();
let footer = draw(&app, 120, 30).lines().last().unwrap().to_string();
assert!(footer.contains("new agent"), "{footer}");
assert!(!footer.contains("delete"), "{footer}");
assert!(footer.trim_end().ends_with("keys"), "{footer}");
}
#[test]
fn the_overlay_has_the_rest() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.keys_open = true;
let out = draw(&app, 100, 30);
assert!(out.contains("keys"));
assert!(out.contains("refresh"), "{out}");
assert!(out.contains("shift+esc / ^]"), "{out}");
assert!(out.contains("any key closes"));
}
#[test]
fn an_agent_shows_its_cards_while_a_session_runs() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
if let Load::Loaded(agents) = &mut app.tree[0].projects[0].envs[0].agents {
agents[0].expanded = true;
agents[0].sessions = LoadSessions::Loaded(vec![
crate::commands::cloud_agent::tui::app::ConsoleSession {
name: "claude-one".into(),
kind: "SHELL".into(),
command: None,
running: true,
attached: true,
},
]);
}
let mut pane =
crate::commands::cloud_agent::tui::session::Session::for_test("ca_1", "nimble-otter")
.unwrap();
pane.durable_name = "claude-one".into();
app.sessions = vec![pane];
app.active = Some(0);
app.focus = ManageFocus::Tree;
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "nimble-otter")
.unwrap();
let out = draw(&app, 110, 30);
assert!(
out.contains("claude-one"),
"the card names the session:\n{out}"
);
assert!(
out.contains("running") || out.contains("attached"),
"the card carries its state:\n{out}"
);
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "claude-one")
.unwrap();
let out = draw(&app, 110, 30);
assert!(
out.contains("nimble-otter · claude-one"),
"the session pane's title:\n{out}"
);
}
#[test]
fn a_long_prompt_scrolls_to_the_cursor() {
let mut app = app_with_tree();
app.prompt = "fix the failing retry tests in the worker service and then \
update the changelog and open a pull request describing what changed"
.repeat(3);
let out = draw(&app, 100, 40);
assert!(
out.contains("describing what changed"),
"the end of the prompt should be visible:\n{out}"
);
}
#[test]
fn wrapped_lines_counts_rows() {
assert_eq!(wrapped_lines("", 10), 1);
assert_eq!(wrapped_lines("short", 10), 1);
assert_eq!(wrapped_lines("one two three", 8), 2);
assert!(wrapped_lines(&"x".repeat(25), 10) >= 3);
assert_eq!(wrapped_lines("anything", 0), 1, "no divide by zero");
}
#[test]
fn a_long_task_does_not_widen_the_loading_panel() {
let mut app = app_with_tree();
app.start_loading(&crate::commands::cloud_agent::tui::LaunchRequest {
project_id: "proj_1".into(),
environment_id: "env_prod".into(),
agent_id: None,
session_name: None,
force_new: false,
new_session: false,
harness: "claude".into(),
prompt: Some(
"fix the failing retry tests in the worker service and update the changelog".into(),
),
label: "devtools/production".into(),
});
app.loading_step("Creating a cloud agent".into());
let out = draw(&app, 120, 30);
let chars: Vec<char> = out
.lines()
.find(|l| l.contains("Creating a cloud agent"))
.unwrap()
.chars()
.collect();
let needle: Vec<char> = "Creating a cloud agent".chars().collect();
let text_at = chars
.windows(needle.len())
.position(|w| w == needle.as_slice())
.expect("the step text");
let block_start = text_at.saturating_sub(2);
let left = (0..block_start).rev().find(|i| chars[*i] == '│').unwrap();
let right = (text_at + needle.len()..chars.len())
.find(|i| chars[*i] == '│')
.unwrap();
let gap_left = block_start - left - 1;
let gap_right = right - (text_at + needle.len());
assert!(
gap_left.abs_diff(gap_right) <= 6,
"the steps should stay centred: left {gap_left}, right {gap_right}\n{out}"
);
}
#[test]
fn wizard_rows_without_a_description_have_no_gap() {
let mut app = app_with_tree();
app.tree[0].projects.push(ProjectNode {
id: "proj_2".into(),
name: "mono".into(),
expanded: false,
envs: vec![EnvNode {
id: "env_stg".into(),
name: "staging".into(),
expanded: false,
agents: Load::NotLoaded,
}],
});
app.skills_source = None;
app.start_wizard(false);
if let Some(w) = app.wizard.as_mut() {
w.step = crate::commands::cloud_agent::tui::wizard::Step::ProjectPick;
}
let out = draw(&app, 100, 30);
let lines: Vec<&str> = out.lines().collect();
let first = lines
.iter()
.position(|l| l.contains("devtools (production)"))
.expect("the project row");
assert!(
lines[first + 1].contains("mono (staging)"),
"rows should be adjacent:\n{out}"
);
}
#[test]
fn manage_survives_an_empty_tree() {
let mut app = App::new(Vec::new(), None, None, None, None, true);
app.screen = Screen::Manage;
let out = draw(&app, 80, 24);
assert!(out.contains("RAILWAY CLOUD-AGENTS"));
}
}