mod bootstrap;
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, Padding, Paragraph, Wrap,
};
use super::app::{
App, KEY_HELP, Load, LoadSessions, ManageFocus, PaneBox, PaneRects, Row, RowKind, Screen,
};
use super::theme::Theme;
use crate::vt100;
const BANNER: &str = r#"██████ █████ ██████ ██ ██ ██ █████ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ███████ ██ ██ ██ █ ██ ███████ ████
██ ██ ██ ██ ██ ██ ███████ ██ ██ ██
██ ██ ██ ██ ██████ ███████ ██ ██ ██ ██ ██"#;
const BANNER_W: u16 = 55;
const BANNER_H: u16 = 5;
const TREE_W: u16 = 32;
pub(super) fn sidebar_width(available: u16, preferred: Option<u16>) -> u16 {
let maximum = available.saturating_sub(32);
preferred.unwrap_or(TREE_W).clamp(20.min(maximum), maximum)
}
const DIALOG_CHROME_X: u16 = 2;
const DIALOG_CHROME_Y: u16 = 2;
const PAGE_MARGIN_X: u16 = 2;
const PAGE_MARGIN_Y: u16 = 1;
fn page_size(width: u16, height: u16) -> (u16, u16) {
if width < 40 || height < 12 {
(width, height)
} else {
(width - PAGE_MARGIN_X * 2, height - PAGE_MARGIN_Y * 2)
}
}
fn page(f: &Frame) -> Rect {
let area = f.area();
let (width, height) = page_size(area.width, area.height);
Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
}
}
fn prompt_box_size(area: Rect) -> (u16, u16) {
let height = if area.height >= 30 { 6 } else { 2 } + DIALOG_CHROME_Y;
let width = 74.min(area.width.saturating_sub(2)).max(40.min(area.width));
(width, height)
}
fn dialog_block(theme: &Theme) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent))
.style(Style::default().bg(theme.surface).fg(theme.fg))
}
fn chord_badge(theme: &Theme, chord: &str) -> Span<'static> {
Span::styled(
format!(" {chord} "),
Style::default()
.fg(theme.on_accent)
.bg(theme.accent_dim)
.add_modifier(Modifier::BOLD),
)
}
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(chord_badge(theme, chord));
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_ssh_gate(app, f);
render_toast(app, f, rects);
}
fn render_ssh_gate(app: &App, f: &mut Frame) {
let Some(gate) = app.ssh_gate.as_ref() else {
return;
};
let theme = app.theme;
let area = page(f);
let body = Style::default().fg(theme.fg);
let lines = vec![
Line::from(Span::styled(
format!(" {}", gate.offer.name),
body.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(format!(" {}", gate.offer.fingerprint), body)),
Line::from(""),
Line::from(Span::styled(
" Agents are reached over SSH, and Railway only answers",
body,
)),
Line::from(Span::styled(
" keys it knows. Registered once, it covers every agent.",
body,
)),
Line::from(""),
Line::from(vec![
chord_badge(theme, "y"),
Span::styled(" Yes — register this key", body),
Span::raw(" "),
chord_badge(theme, "n"),
Span::styled(" No, not now", body),
])
.alignment(Alignment::Center),
];
let width = (55 + DIALOG_CHROME_X).min(area.width.saturating_sub(4));
let height = (lines.len() as u16 + DIALOG_CHROME_Y).min(area.height.saturating_sub(2));
let panel = centered(width, height, area);
f.render_widget(Clear, panel);
f.render_widget(
Paragraph::new(lines).block(
dialog_block(theme).title(Span::styled(
" Register your SSH key with Railway? ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
),
panel,
);
}
fn render_toast(app: &App, f: &mut Frame, rects: &PaneRects) {
let Some(toast) = app.toast.as_ref().filter(|toast| !toast.expired()) else {
return;
};
let theme = app.theme;
let area = page(f);
let text = format!(" {} {} ", if toast.ok { "✓" } else { "✕" }, toast.text);
let w = (text.chars().count() as u16 + DIALOG_CHROME_X).min(area.width);
let h = 3.min(area.height);
let rect = if toast.ok {
Rect {
x: area.right().saturating_sub(w + 2),
y: area.bottom().saturating_sub(h + 2),
width: w,
height: h,
}
} else {
let host = if rects.session.w > 0 {
Rect {
x: rects.session.x,
y: rects.session.y,
width: rects.session.w,
height: rects.session.h,
}
} else {
area
};
let w = w.min(host.width);
Rect {
x: host.x + host.width.saturating_sub(w) / 2,
y: host.bottom().saturating_sub(h + 1).max(host.y),
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(dialog_block(theme).border_style(Style::default().fg(accent))),
rect,
);
}
fn render_screen(app: &App, f: &mut Frame, rects: &mut PaneRects) {
match app.screen {
Screen::BootstrapSetup | Screen::BootstrapPick => {
render_manage(app, f, rects);
bootstrap::render(app, f, rects);
}
Screen::Setup => {
render_manage(app, f, rects);
render_wizard(app, f);
}
Screen::Settings => {
render_manage(app, f, rects);
render_settings(app, f);
}
Screen::Manage => render_manage(app, f, rects),
Screen::TargetPick => {
render_manage(app, f, rects);
render_target_pick(app, f);
}
Screen::HarnessPick => {
render_manage(app, f, rects);
render_harness_pick(app, f, rects);
}
Screen::ManagePrompt => {
render_manage(app, f, rects);
render_manage_prompt(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 terminal_block(app: &App, frame_width: u16) -> Block<'static> {
let split = !app.pane_is_full() && frame_width.saturating_sub(PAGE_MARGIN_X * 2) >= 70;
Block::default()
.borders(if split {
Borders::TOP | Borders::BOTTOM | Borders::RIGHT
} else {
Borders::ALL
})
.padding(if split {
Padding::left(1)
} else {
Padding::ZERO
})
.border_type(BorderType::Rounded)
}
fn render_welcome(app: &App, f: &mut Frame, pane: Rect, rects: &mut PaneRects) {
let theme = app.theme;
let focused = app.new_session_selected();
let block = terminal_block(app, f.area().width)
.border_style(Style::default().fg(if focused {
theme.accent
} else {
theme.accent_dim
}))
.title(Span::styled(" new agent ", Style::default().fg(theme.dim)));
let area = {
let inner = block.inner(pane);
f.render_widget(block, pane);
inner
};
let big = area.width >= BANNER_W + 2 && area.height >= 20;
let banner_h = if big { BANNER_H } else { 1 };
let (panel_w, prompt_h) = prompt_box_size(area);
let prompt_gap = if area.height >= 26 { 2 } else { 1 };
let bootstrap_h = if app.target.is_some() { 2 } else { 0 };
let panel_h = banner_h + 4 + 1 + prompt_h + prompt_gap + 1 + bootstrap_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(prompt_gap), Constraint::Length(1), Constraint::Length(bootstrap_h), ])
.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], focused);
rects.prompt = whole(rows[6]);
f.render_widget(
Paragraph::new(target_line(app)).alignment(Alignment::Center),
rows[8],
);
if let Some(target) = &app.target {
use super::bootstrap_setup::DefaultState;
let text = match app.bootstrap_defaults.get(&target.environment_id) {
Some(DefaultState::Ready(name)) => format!("Select Bootstrap {name} (default)"),
Some(DefaultState::Available) => "Select Bootstrap".into(),
Some(DefaultState::Failed(error)) => format!(
"Bootstrap unavailable: {} — configure a new one",
error.lines().next().unwrap_or("retry")
),
Some(DefaultState::Missing) => "No bootstrap configured — set one up".into(),
_ => "Checking bootstrap…".into(),
};
f.render_widget(
Paragraph::new(Line::from(vec![
chord_badge(theme, "⌥b"),
Span::raw(" "),
Span::styled(text, Style::default().fg(theme.accent)),
]))
.alignment(Alignment::Center),
Rect::new(
rows[9].x,
rows[9].y + 1,
rows[9].width,
rows[9].height.saturating_sub(1),
),
);
rects.bootstrap = whole(Rect::new(
rows[9].x,
rows[9].y + 1,
rows[9].width,
rows[9].height.saturating_sub(1),
));
}
}
fn target_line(app: &App) -> Line<'static> {
let theme = app.theme;
let mut spans = vec![
chord_badge(theme, "⌥t"),
Span::raw(" "),
Span::styled(
"Target Project ",
Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
),
];
spans.push(match app.target.as_ref() {
Some(target) => Span::styled(
format!("{} ({})", target.project_name, target.environment_name),
Style::default().fg(theme.accent),
),
None => Span::styled("not set", Style::default().fg(theme.pending)),
});
Line::from(spans)
}
fn render_loading(app: &App, f: &mut Frame, area: Rect) {
let theme = app.theme;
let loading = &app.loading;
let block = terminal_block(app, f.area().width)
.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
};
let width = 56.min(area.width.saturating_sub(2));
let prompt_h = if loading.prompt.is_some() { 4 } else { 0 };
let gap = u16::from(prompt_h > 0);
let panel = centered(width, 10 + prompt_h + gap, area);
let rows = Layout::vertical([
Constraint::Length(prompt_h),
Constraint::Length(gap),
Constraint::Min(3),
])
.split(panel);
if let Some(prompt) = &loading.prompt {
f.render_widget(
Paragraph::new(prompt.clone())
.block(
dialog_block(theme)
.title(" Prompt ")
.border_style(Style::default().fg(theme.accent_dim)),
)
.style(Style::default().fg(theme.fg))
.wrap(Wrap { trim: true }),
rows[0],
);
}
let block = dialog_block(theme)
.title(" Preparing agent ")
.padding(ratatui::widgets::Padding::new(2, 2, 1, 1));
let inner = block.inner(rows[2]);
f.render_widget(block, rows[2]);
let lines = vec![
Line::styled(loading.target.clone(), Style::default().fg(theme.dim)),
Line::raw(""),
Line::styled(
spinner_frame(loading.tick).to_string(),
Style::default().fg(theme.accent),
),
Line::raw(""),
Line::styled(
loading
.steps
.last()
.cloned()
.unwrap_or_else(|| "Preparing the agent".into()),
Style::default().fg(theme.accent),
),
];
f.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
inner,
);
}
fn spinner_frame(tick: usize) -> char {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
FRAMES[tick % FRAMES.len()]
}
fn render_prompt(app: &App, f: &mut Frame, area: Rect, focused: bool) {
let theme = app.theme;
let empty = app.prompt.is_empty();
let (text, fg) = if app.shell_selected() {
(
"A plain shell on the agent — no prompt, enter to launch".to_string(),
theme.dim,
)
} else if empty && !focused {
(
"Fix a bug, scaffold a service, explain a repo…".to_string(),
theme.dim,
)
} else if focused {
let at = app
.prompt
.char_indices()
.nth(app.prompt_cursor)
.map(|(i, _)| i)
.unwrap_or(app.prompt.len());
(
format!("{}▏{}", &app.prompt[..at], &app.prompt[at..]),
theme.fg,
)
} else {
(app.prompt.clone(), theme.fg)
};
let count = if empty || app.shell_selected() {
String::new()
} else {
format!(" {} ", app.prompt.chars().count())
};
f.render_widget(Clear, area);
let block = dialog_block(theme)
.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!(" {} ", super::app::harness_label(app.harness_name())),
Style::default()
.fg(if focused { theme.accent } else { theme.fg })
.add_modifier(Modifier::BOLD),
),
Span::styled(
if super::app::opencode_alternate(app.harness).is_some() {
"shift+tab · tab version "
} else {
"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(DIALOG_CHROME_X).max(1) as usize;
let inner_h = area.height.saturating_sub(DIALOG_CHROME_Y).max(1) as usize;
let total = wrapped_lines(&text, inner_w);
let tail_pin = total.saturating_sub(inner_h);
let scroll_y = if focused && !empty && !app.shell_selected() {
let caret_end = text
.char_indices()
.nth(app.prompt_cursor + 1)
.map(|(i, _)| i)
.unwrap_or(text.len());
let caret_row = wrapped_lines(&text[..caret_end], inner_w);
caret_row.saturating_sub(inner_h).min(tail_pin)
} else {
tail_pin
} 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 = 0usize;
for line in text.split('\n') {
rows += 1;
let mut column = 0usize;
for word in line.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.max(1)
}
pub fn session_pane_size(
area: Option<ratatui::layout::Size>,
maximized: bool,
preferred_sidebar_width: Option<u16>,
) -> Option<(u16, u16)> {
let area = area?;
let (width, height) = page_size(area.width, area.height);
if !maximized && width < 70 {
return None;
}
let rows = height.saturating_sub(3).saturating_sub(2).max(1);
let tree = if maximized {
0
} else {
sidebar_width(width, preferred_sidebar_width)
};
let cols = 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 = page(f);
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ])
.split(area);
let rows = app.rows();
let full = app.pane_is_full();
let mut header = vec![Span::styled(
" RAILWAY CLOUD-AGENTS ",
Style::default()
.fg(theme.on_accent)
.bg(theme.accent)
.add_modifier(Modifier::BOLD),
)];
if full && !app.sessions.is_empty() && !app.hide_tabs {
let mut x = chunks[0].x + " RAILWAY CLOUD-AGENTS ".chars().count() as u16;
for i in 0..app.sessions.len() {
let label = format!(" {} {} ", i + 1, app.session_tab_label(i));
header.push(Span::raw(" "));
x += 1;
let w = label.chars().count() as u16;
if let Some(slot) = rects.tabs.get_mut(i) {
*slot = PaneBox {
x,
y: chunks[0].y,
w,
h: 1,
};
}
let on = app.active == Some(i);
header.push(Span::styled(
label,
if on {
Style::default()
.fg(theme.on_accent)
.bg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.dim).bg(theme.surface)
},
));
x += w;
}
} else if !app.status.is_empty() {
header.push(Span::styled(
format!(" · {}", app.status),
Style::default().fg(theme.dim),
));
}
f.render_widget(Paragraph::new(Line::from(header)), chunks[0]);
let two_pane = !full && chunks[2].width >= 70;
let panes = if two_pane {
Layout::horizontal([
Constraint::Length(sidebar_width(chunks[2].width, app.sidebar_width)),
Constraint::Min(32),
])
.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 app.loading.active {
render_loading(app, f, pane);
} else if let Some(session) = app.active_session() {
render_session(app, session, f, pane);
}
render_manage_footer(app, f, chunks[3], rects);
return;
}
if !two_pane && !app.loading.active && app.launcher_selected() {
render_welcome(app, f, panes[0], rects);
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, app, panes[0].width.saturating_sub(2))))
.collect();
let mut state = ListState::default();
state.select(if rows.is_empty() {
None
} else {
Some(app.cursor)
});
f.render_stateful_widget(
List::new(items)
.style(Style::default().bg(theme.sidebar))
.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(" threads ", 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]);
rects.sidebar_divider = PaneBox {
x: panes[0].right() - 1,
y: panes[0].y + 1,
w: 2,
h: panes[0].height.saturating_sub(2),
};
let selected_kind = app.selected_row().map(|row| row.kind);
if app.loading.active {
render_loading(app, f, panes[1]);
} else {
match app
.displayed_session_index()
.and_then(|i| app.sessions.get(i))
{
Some(session) => render_session(app, session, f, panes[1]),
None if matches!(selected_kind, Some(RowKind::NewSession)) => {
render_welcome(app, f, panes[1], rects)
}
None => {
let title = if matches!(
selected_kind,
Some(RowKind::Session(..)) | Some(RowKind::Agent(..))
) {
" thread "
} else {
" detail "
};
f.render_widget(
Paragraph::new(detail_lines(app)).block(
terminal_block(app, f.area().width)
.border_style(Style::default().fg(if tree_focused {
theme.accent_dim
} else {
theme.accent
}))
.title(Span::styled(title, Style::default().fg(theme.dim))),
),
panes[1],
)
}
}
}
}
if two_pane {
f.render_widget(
Paragraph::new("↔").style(Style::default().fg(if app.resizing_sidebar() {
theme.accent
} else {
theme.dim
})),
Rect::new(panes[0].right() - 1, panes[0].y + panes[0].height / 2, 1, 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 matches!(app.screen, Screen::BootstrapSetup | Screen::BootstrapPick) {
bootstrap::footer(app, f, area);
return;
}
if app.screen == Screen::HarnessPick {
let mut hints = vec![
("↑↓", "choose agent"),
(
"enter",
if app.harness_pick_connect {
"connect"
} else if app.harness_pick_agent.is_some() {
"new session"
} else {
"create VM"
},
),
("esc", "back"),
];
if app
.harness_pick
.is_some_and(|h| super::app::opencode_alternate(h).is_some())
{
hints.push(("tab", "version"));
}
f.render_widget(Paragraph::new(Line::from(chord_spans(theme, &hints))), area);
return;
}
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.pane_is_full() {
vec![
("⌥f", "restore the tree"),
("⌥o", "SSH shell"),
("⌥esc", "stop typing"),
]
} else if app.focus == ManageFocus::Session {
if app.active_session().is_none() {
let mut keys = vec![("esc", "back to the tree")];
if app
.selected_row()
.is_some_and(|row| matches!(row.kind, RowKind::Session(..) | RowKind::Agent(..)))
{
keys.insert(0, ("enter", "connect"));
}
keys
} else if app
.active_session()
.is_some_and(|s| s.ended() || s.stalled())
{
vec![
("r", "reconnect"),
("x", "close pane"),
("esc", "back to the tree"),
]
} else {
let mut keys = vec![
("⌥esc", "stop typing"),
("⌥f", "maximize"),
("⌥o", "SSH shell"),
];
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::NewSession) if app.new_session_selected() => vec![
("enter", "launch"),
("shift+tab", "agent"),
("↓", "threads"),
("esc", "home"),
("⌥s", "settings"),
],
Some(RowKind::NewSession) => vec![
("enter", "write a prompt"),
("↓", "threads"),
("⌥s", "settings"),
("q", "quit"),
],
Some(RowKind::Session(w, p, e, a, i)) => {
let conversation = app
.console_session(w, p, e, a, i)
.is_some_and(|s| super::super::client_sessions::is_client(&s.name));
vec![
("enter", if conversation { "resume" } else { "connect" }),
("⌥o", "SSH shell"),
("⌥f", "maximize"),
(
"⌥enter",
if conversation {
"maximize"
} else {
"full screen"
},
),
("c", "copy shell"),
(
"x",
if conversation {
"delete thread"
} else {
"end session"
},
),
if sleeping {
("w", "wake")
} else {
("s", "sleep")
},
("d", "delete agent"),
]
}
Some(RowKind::Agent(..)) => vec![
("enter", "connect"),
("⌥o", "shell"),
("n", "new VM"),
("⌥n", "new session"),
if sleeping {
("w", "wake")
} else {
("s", "sleep")
},
("⌥b", "save bootstrap"),
("d", "delete"),
],
Some(RowKind::Project(..) | RowKind::Environment(..)) => vec![
("enter", "open"),
("n", "new VM"),
(
"⌥b",
if app.bootstrap_target().is_some_and(|t| {
matches!(
app.bootstrap_defaults.get(&t.environment_id),
Some(
super::bootstrap_setup::DefaultState::Ready(_)
| super::bootstrap_setup::DefaultState::Available
)
)
}) {
"Select Bootstrap"
} else {
"Create Bootstrap"
},
),
("⌥r", "refresh"),
],
_ => vec![
("enter", "open"),
("n", "new VM"),
("⌥r", "refresh"),
("shift+r", "find agents"),
],
}
};
let mut hint = hint;
if app.sessions.len() > 1 {
hint.push(("⌥⇧[ ⌥⇧]", "switch session"));
}
let spans = chord_spans(theme, &hint);
f.render_widget(Paragraph::new(Line::from(spans)), area);
f.render_widget(
Paragraph::new(Line::from(vec![
chord_badge(theme, "?"),
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 = (62 + DIALOG_CHROME_X).min(area.width.saturating_sub(4));
let height = (body_h + dots_h + 5 + DIALOG_CHROME_Y).min(area.height.saturating_sub(2));
let outer = centered(width, height, area);
f.render_widget(Clear, outer);
let block = dialog_block(theme).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 avail = rows[3].height as usize;
let row_height = |row: &PanelRow| if row.detail.is_empty() { 1 } else { 2 };
let mut first = 0usize;
while first < panel.cursor
&& panel.rows[first..=panel.cursor.min(panel.rows.len() - 1)]
.iter()
.map(row_height)
.sum::<usize>()
> avail
{
first += 1;
}
let mut lines: Vec<Line> = Vec::with_capacity(panel.rows.len() * 2);
for (i, row) in panel.rows.iter().enumerate().skip(first) {
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(if on { theme.fg } else { 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,
page(f),
Panel {
title: "setup",
heading: wizard.title(),
position: wizard.position(),
rows: &rows,
cursor: wizard.cursor,
footer,
},
);
}
fn render_settings(app: &App, f: &mut Frame) {
let Some(settings) = app.settings.as_ref() else {
return;
};
let theme = app.theme;
if let Some(pick) = settings.pick {
let rows: Vec<PanelRow> = settings
.picker_options()
.into_iter()
.map(|(label, tag, _)| PanelRow {
label,
tag,
detail: String::new(),
})
.collect();
let footer = if let Some(busy) = settings.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) = settings.error.as_deref() {
Line::from(Span::styled(
format!(" {error}"),
Style::default().fg(theme.pending),
))
} else {
Line::from(chord_spans(
theme,
&[("↑↓", "choose"), ("enter", "set default"), ("esc", "back")],
))
};
render_panel(
f,
theme,
page(f),
Panel {
title: "settings",
heading: "Where should agents live?",
position: None,
rows: &rows,
cursor: pick,
footer,
},
);
return;
}
let cycles = settings.cycles();
let rows: Vec<PanelRow> = settings
.options()
.into_iter()
.enumerate()
.map(|(i, (label, value, _))| PanelRow {
label: format!("{label:<19}"),
tag: if i == settings.cursor && cycles {
format!("‹ {value} ›")
} else {
value
},
detail: String::new(),
})
.collect();
let footer = Line::from(chord_spans(
theme,
&[
("↑↓", "choose"),
("←→", "change"),
("enter", "edit"),
("esc", "close"),
],
));
render_panel(
f,
theme,
page(f),
Panel {
title: "settings",
heading: "Cloud agent settings",
position: None,
rows: &rows,
cursor: settings.cursor,
footer,
},
);
}
fn render_harness_pick(app: &App, f: &mut Frame, rects: &mut PaneRects) {
use super::bootstrap_setup::{DefaultState, LaunchChoice};
let Some(cursor) = app.harness_pick else {
return;
};
let theme = app.theme;
let indices = super::app::harness_picker_indices(cursor);
let existing = app.harness_pick_agent.is_some();
let target = app.harness_pick_target.as_ref().or(app.target.as_ref());
let host = if rects.session.w > 0 {
let r = rects.session;
Rect::new(r.x, r.y, r.w, r.h)
} else {
page(f)
};
f.render_widget(Clear, host);
let area = centered(
64,
indices.len() as u16 + if existing { 7 } else { 11 },
host,
);
f.render_widget(Clear, area);
let block = dialog_block(theme)
.title(if existing {
if app.harness_pick_connect {
" Connect cloud agent "
} else {
" New session "
}
} else {
" New Cloud Agent "
})
.padding(ratatui::widgets::Padding::horizontal(2));
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(indices.len() as u16),
Constraint::Length(1),
Constraint::Length(if existing { 0 } else { 1 }),
Constraint::Length(if existing { 0 } else { 2 }),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner);
f.render_widget(
Paragraph::new(if app.harness_pick_connect {
"Choose the agent to open on this VM"
} else if existing {
"Choose an agent for this VM"
} else {
"Choose an agent for the new VM"
})
.alignment(Alignment::Center)
.style(Style::default().fg(theme.fg)),
rows[0],
);
f.render_widget(
Paragraph::new(target.map(|t| t.label()).unwrap_or_default())
.alignment(Alignment::Center)
.style(Style::default().fg(theme.dim)),
rows[1],
);
let items: Vec<_> = indices
.iter()
.map(|i| {
let label = match super::app::HARNESSES[*i] {
"railway" => "Railway",
"grok" => "Grok Build",
"codex" => "ChatGPT Codex",
"claude" => "Claude Code",
"opencode" => "OpenCode",
"opencode2" => "OpenCode2 [Beta]",
"shell" => "Shell",
other => other,
};
ListItem::new(label)
})
.collect();
let mut state = ListState::default();
state.select(indices.iter().position(|i| *i == cursor));
f.render_stateful_widget(
List::new(items).highlight_symbol("› ").highlight_style(
Style::default()
.fg(theme.accent)
.bg(theme.selection)
.add_modifier(Modifier::BOLD),
),
rows[3],
&mut state,
);
rects.harness_list = whole(rows[3]);
if !existing {
let controls =
|row: Rect| Rect::new(row.x + 2, row.y, row.width.saturating_sub(2), row.height);
let checkbox = controls(rows[5]);
let selector = controls(rows[6]);
let default_name = target.and_then(|t| app.bootstrap_defaults.get(&t.environment_id));
let selected = match &app.harness_bootstrap {
LaunchChoice::Named(name) => name.clone(),
LaunchChoice::None => "none".into(),
LaunchChoice::Default => match default_name {
Some(DefaultState::Ready(name)) => format!("{name} (project default)"),
Some(DefaultState::Loading) | None => "checking project default…".into(),
_ => "project default: none".into(),
},
};
f.render_widget(
Paragraph::new(Line::from(vec![
chord_badge(theme, "space"),
Span::raw(if app.harness_use_bootstrap {
" [✓] Use bootstrap"
} else {
" [ ] Use bootstrap · clean VM"
}),
])),
checkbox,
);
f.render_widget(
Paragraph::new(vec![
Line::from(vec![
chord_badge(theme, "⌥b"),
Span::raw(" Select Bootstrap"),
]),
Line::from(selected).style(Style::default().fg(theme.dim)),
]),
selector,
);
rects.harness_use_bootstrap = whole(checkbox);
rects.harness_bootstrap = whole(selector);
}
}
fn render_manage_prompt(app: &App, f: &mut Frame) {
let Some(draft) = app.manage_prompt.as_ref() else {
return;
};
let theme = app.theme;
let area = page(f);
let (w, h) = prompt_box_size(area);
let outer = centered(w, h, area);
f.render_widget(Clear, outer);
let block = dialog_block(theme)
.title(Span::styled(
" New Session ",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))
.title_bottom(Line::from(vec![
Span::styled(
format!(" {} ", super::app::harness_label(app.harness_name())),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
if super::app::opencode_alternate(app.harness).is_some() {
"shift+tab · tab version "
} else {
"shift+tab "
},
Style::default().fg(theme.dim),
),
]))
.title_bottom(
Line::from(Span::styled(
if app.shell_selected() {
" enter launch · esc close "
} else {
" enter send · ⇧enter newline · esc close "
},
Style::default().fg(theme.dim),
))
.right_aligned(),
);
let (text, fg) = if app.shell_selected() {
(
"A plain shell on the agent — no prompt, enter to launch".to_string(),
theme.dim,
)
} else {
(format!("{draft}▏"), theme.fg)
};
let inner_w = outer.width.saturating_sub(DIALOG_CHROME_X).max(1) as usize;
let inner_h = outer.height.saturating_sub(DIALOG_CHROME_Y).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(ratatui::widgets::Wrap { trim: false })
.scroll((scroll_y, 0)),
outer,
);
}
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,
page(f),
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 = page(f);
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(
(*group).to_string(),
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 rows = lines.len() as u16;
let content_w = lines
.iter()
.map(|line| line.width() as u16)
.max()
.unwrap_or(48);
let width = (content_w + DIALOG_CHROME_X).min(area.width.saturating_sub(4));
let height = (rows + DIALOG_CHROME_Y).min(area.height.saturating_sub(2));
let panel = centered(width, height, area);
f.render_widget(Clear, panel);
f.render_widget(
Paragraph::new(lines).block(
dialog_block(theme)
.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!(" {} ", app.pane_breadcrumb(session));
let block = terminal_block(app, f.area().width)
.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() {
if focused {
" connection closed · r reconnects · x closes "
} else {
" connection closed · enter on its row reconnects "
}
} else if session.stalled() {
" no response "
} 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);
if session.stalled() {
let dim = Style::default().fg(theme.dim);
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled("Nothing has arrived from this session.", dim)),
Line::from(Span::styled(
"It may have ended when the agent last slept —",
dim,
)),
Line::from(Span::styled("r dials it again, x closes this pane.", dim)),
])
.alignment(ratatui::layout::Alignment::Center),
inner,
);
return;
}
let Some(lines) = session.with_screen(|screen| screen_lines(screen, focused)) else {
return;
};
f.render_widget(Paragraph::new(lines), inner);
if session.ended() && inner.height > 0 {
let banner = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
};
f.render_widget(Clear, banner);
f.render_widget(
Paragraph::new(if focused {
" ✕ disconnected — press r to reconnect · x closes "
} else {
" ✕ disconnected — click here (or enter on its row) to reconnect "
})
.alignment(ratatui::layout::Alignment::Center)
.style(
Style::default()
.bg(theme.pending)
.fg(theme.on_accent)
.add_modifier(Modifier::BOLD),
),
banner,
);
}
}
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 {
if screen
.cell(row, col)
.is_some_and(vt100::Cell::is_wide_continuation)
{
continue;
}
let (text, mut style) = match screen.cell(row, col) {
Some(cell) => (
{
let c = cell.contents();
if c.is_empty() {
" ".to_string()
} else {
c.to_string()
}
},
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.dim() {
style = style.add_modifier(Modifier::DIM);
}
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, app: &App, width: u16) -> Line<'static> {
let tick = app.loading.tick;
let indent = " ".repeat(row.depth);
let label_width = usize::from(width).saturating_sub(indent.len() + 2);
let label = if label_width == 0 {
String::new()
} else if console::measure_text_width(&row.label) > label_width {
console::truncate_str(&row.label, label_width, "…").into_owned()
} else {
row.label.clone()
};
let mut spans = vec![Span::raw(indent)];
match (&row.kind, row.expanded) {
(RowKind::NewSession, _) => {
spans.push(Span::styled("+ ", Style::default().fg(theme.accent)));
spans.push(Span::styled(
label.clone(),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
));
}
(RowKind::Agent(..), _) => {
let status = row.status.as_deref().unwrap_or_default();
let (glyph, color) = (status_glyph(status), status_color(theme, status));
spans.push(Span::styled(
format!("{glyph} "),
Style::default().fg(color),
));
spans.push(Span::styled(label.clone(), Style::default().fg(theme.fg)));
}
(RowKind::Session(..), _) => {
let (glyph, color) = match row.status.as_deref() {
Some("connecting") => (format!("{} ", spinner_frame(tick)), theme.pending),
Some("working") => (format!("{} ", spinner_frame(tick)), theme.running),
Some("waiting") => ("◌ ".to_string(), theme.pending),
Some(_) => ("● ".to_string(), theme.running),
None => ("↳ ".to_string(), theme.dim),
};
spans.push(Span::styled(glyph, Style::default().fg(color)));
spans.push(Span::styled(label.clone(), Style::default().fg(theme.fg)));
}
(RowKind::Separator, _) => spans.push(Span::styled(
"─".repeat(width.saturating_sub(2) as usize),
Style::default().fg(theme.accent_dim),
)),
(RowKind::Note(..) | RowKind::Hint, _) => spans.push(Span::styled(
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(label.clone(), style));
}
_ => spans.push(Span::raw(label.clone())),
}
if !row.note.is_empty() {
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::NewSession => vec![Line::from(Span::styled(
" enter launches a new session",
Style::default().fg(theme.dim),
))],
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(Span::styled(
format!(" {} {}", status_glyph(&status), status),
Style::default().fg(status_color(theme, &status)),
)),
Line::from(""),
kv("project", proj.name.clone()),
kv("env", env.name.clone()),
kv("agent", name),
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 session running on it",
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.short_name(),
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 — click 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 its 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(
" no session running on it",
Style::default().fg(theme.dim),
))),
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
if status == "running" {
" enter / double-click connects · n new agent here"
} else {
" w wakes it · enter / double-click connects"
},
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 proj = &app.tree[w].projects[p];
let env = &proj.envs[e];
let agent_name = match &env.agents {
Load::Loaded(list) => list.get(a).map(|agent| agent.name.clone()),
_ => None,
}
.unwrap_or_default();
let mut lines = vec![Line::from(Span::styled(
format!(" {}", row.label),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
))];
let session = app.console_session(w, p, e, a, i);
if let Some(session) = session {
lines.push(Line::from(""));
lines.push(kv("project", proj.name.clone()));
lines.push(kv("env", env.name.clone()));
lines.push(kv("agent", agent_name));
lines.push(Line::from(""));
lines.push(kv("session", session.name.clone()));
lines.push(kv(
"state",
session_state(app, &session.name, session.running).to_string(),
));
lines.push(kv("kind", session.kind.to_lowercase()));
if let Some(snapshot) = &session.snapshot {
lines.push(kv("thread", snapshot.state.clone()));
if let Some(text) = snapshot
.latest_prompt
.as_deref()
.or(snapshot.prompt.as_deref())
{
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" › ", Style::default().fg(theme.dim)),
Span::styled(truncate(text, 200), Style::default().fg(theme.fg)),
]));
}
if let Some(reply) = snapshot.last_reply.as_deref() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", truncate(reply, 300)),
Style::default().fg(theme.fg),
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", session.command_summary()),
Style::default().fg(theme.dim),
)));
}
lines.push(Line::from(""));
let connected = session
.is_some_and(|s| app.sessions.iter().any(|pane| pane.durable_name == s.name));
if connected {
lines.push(Line::from(Span::styled(
" enter puts the keyboard in its pane",
Style::default().fg(theme.dim),
)));
} else {
let sleeping = app
.selected_agent_status()
.is_some_and(|status| status != "running");
lines.push(Line::from(Span::styled(
" not connected — its output isn't shown here",
Style::default().fg(theme.pending),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
if sleeping {
" w wakes the agent, then enter / double-click connects"
} else {
" enter / double-click connects · c copies the ssh command"
},
Style::default().fg(theme.dim),
)));
}
lines
}
RowKind::OtherProjects => vec![
Line::from(Span::styled(
" projects without agents",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
" open one and press n to start an agent there",
Style::default().fg(theme.dim),
)),
],
RowKind::Separator | RowKind::Note(..) | RowKind::Hint => 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 {
id: "ws_1".into(),
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,
)
}
#[test]
fn bootstrap_setup_row_is_below_project_and_only_visible_with_target() {
let mut app = app_with_tree();
app.bootstrap_defaults.insert(
"env_prod".into(),
super::super::bootstrap_setup::DefaultState::Missing,
);
let screen = draw(&app, 120, 40);
let lines: Vec<_> = screen.lines().collect();
let project = lines
.iter()
.position(|l| l.contains("Target Project"))
.unwrap();
let bootstrap = lines
.iter()
.position(|l| l.contains("No bootstrap configured"))
.unwrap();
assert!(bootstrap >= project + 2);
assert!(lines[bootstrap].contains("⌥b"));
app.target = None;
let screen = draw(&app, 120, 40);
assert!(!screen.contains("No bootstrap configured"));
assert!(!screen.contains("Checking bootstrap"));
}
#[test]
fn bootstrap_setup_form_and_progress_keep_the_prompt_draft() {
let mut app = app_with_tree();
app.prompt = "Fix the CLI".into();
app.start_bootstrap_setup();
let screen = draw(&app, 120, 40);
for text in [
"Create bootstrap",
"Repository (optional)",
"Coding agent",
"Name",
] {
assert!(screen.contains(text), "{screen}");
}
let form = app.bootstrap_form.as_mut().unwrap();
form.running = true;
form.steps = vec!["Creating setup VM".into(), "Saving checkpoint".into()];
let screen = draw(&app, 120, 40);
assert!(screen.contains("Creating bootstrap"));
assert!(screen.contains("Saving checkpoint"));
assert_eq!(app.prompt, "Fix the CLI");
draw(&app, 60, 18);
}
fn layout(app: &mut App, width: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, 40)).unwrap();
terminal
.draw(|f| app.panes = render_with_layout(app, f).0)
.unwrap();
if let Some((rows, cols)) = session_pane_size(
Some(ratatui::layout::Size::new(width, 40)),
app.pane_is_full(),
app.sidebar_width,
) {
assert_eq!((app.panes.session.h, app.panes.session.w), (rows, cols));
}
}
#[test]
fn sidebar_drag_resizes_both_panes_and_keeps_the_preferred_width() {
use crate::commands::cloud_agent::tui::app::{Effect, MouseAction};
let mut app = app_with_tree();
app.focus = ManageFocus::Session;
layout(&mut app, 120);
assert_eq!(app.panes.tree_outer.w, TREE_W);
let cursor = app.cursor;
let divider = app.panes.sidebar_divider;
assert_eq!(
app.on_mouse(MouseAction::Down, divider.x + 1, divider.y),
None
);
assert!(app.resizing_sidebar());
assert_eq!(
app.on_mouse(MouseAction::Drag, divider.x + 27, divider.y),
None
);
assert_eq!(app.sidebar_width, Some(58));
layout(&mut app, 120);
assert_eq!(app.panes.tree_outer.w, 58);
assert_eq!(
app.on_mouse(MouseAction::Up, divider.x + 27, divider.y),
Some(Effect::SaveSidebarWidth(58))
);
assert!(!app.resizing_sidebar());
assert_eq!(app.focus, ManageFocus::Session);
assert_eq!(app.cursor, cursor);
assert!(app.selection.is_none() && app.pending_copy.is_none());
layout(&mut app, 80);
assert_eq!(app.panes.tree_outer.w, 44);
assert_eq!(
app.sidebar_width,
Some(58),
"shrinking the window must not overwrite the preference"
);
layout(&mut app, 120);
assert_eq!(app.panes.tree_outer.w, 58);
let divider = app.panes.sidebar_divider;
app.on_mouse(MouseAction::Down, divider.x, divider.y);
app.on_mouse(MouseAction::Drag, u16::MAX, divider.y);
assert_eq!(
app.sidebar_width,
Some(84),
"leave at least 32 columns for the terminal"
);
app.on_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
));
assert_eq!(app.sidebar_width, Some(58), "Escape cancels the resize");
app.on_mouse(MouseAction::Down, divider.x, divider.y);
app.on_mouse(MouseAction::Drag, 0, divider.y);
assert_eq!(app.sidebar_width, Some(20));
assert_eq!(
app.on_mouse(MouseAction::Up, 0, divider.y),
Some(Effect::SaveSidebarWidth(20))
);
layout(&mut app, 120);
let divider = app.panes.sidebar_divider;
app.on_mouse(MouseAction::Down, divider.x, divider.y);
assert_eq!(
app.on_mouse(MouseAction::Up, divider.x, divider.y),
None,
"clicking without a drag does not save"
);
app.maximized = true;
app.loading.active = true;
layout(&mut app, 120);
assert_eq!(app.panes.sidebar_divider, PaneBox::default());
assert_eq!(app.sidebar_width, Some(20));
}
#[test]
fn clicking_rendered_panels_switches_focus_without_activating_blank_rows() {
use crate::commands::cloud_agent::tui::{app::MouseAction, session::Session};
let mut app = app_with_tree();
app.attach_session(
Session::for_test("ca_1", "nimble-otter").unwrap(),
"ca_1".into(),
);
for width in [None, Some(58)] {
app.sidebar_width = width;
layout(&mut app, 120);
let panes = app.panes;
let cursor = app.cursor;
let active = app.active;
let blank_row = panes.tree.y + app.rows().len() as u16 + 1;
assert!(panes.tree.contains(panes.tree.x, blank_row));
let separator = app
.rows()
.iter()
.position(|row| matches!(row.kind, RowKind::Separator))
.unwrap();
for (col, row) in [
(panes.tree.x, blank_row),
(panes.tree.x, panes.tree.y + separator as u16),
(panes.tree_outer.x, panes.tree.y),
(panes.tree.x, panes.tree_outer.y),
(panes.sidebar_divider.x, panes.sidebar_divider.y),
] {
for _ in 0..2 {
app.focus = ManageFocus::Session;
assert_eq!(app.on_mouse(MouseAction::Down, col, row), None);
assert_eq!(app.on_mouse(MouseAction::Up, col, row), None);
assert_eq!(app.focus, ManageFocus::Tree, "click at {col},{row}");
assert_eq!(app.cursor, cursor);
assert_eq!(app.active, active);
assert_eq!(app.sidebar_width, width);
assert!(!app.resizing_sidebar());
}
}
for (col, row) in [
(panes.session.x + 3, panes.session.y + 3),
(panes.session.x, panes.session_outer.y),
(panes.sidebar_divider.x + 1, panes.sidebar_divider.y),
] {
app.focus = ManageFocus::Tree;
assert_eq!(app.on_mouse(MouseAction::Down, col, row), None);
assert_eq!(app.on_mouse(MouseAction::Up, col, row), None);
assert_eq!(app.focus, ManageFocus::Session, "click at {col},{row}");
assert_eq!(app.sidebar_width, width);
assert!(!app.resizing_sidebar());
}
}
}
#[test]
fn focusing_an_unconnected_thread_keeps_its_card_and_background_sessions_separate() {
use crate::commands::cloud_agent::tui::{
app::{ConsoleSession, Effect, MouseAction},
session::Session,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let remote_thread = |name: &str| ConsoleSession {
name: name.into(),
kind: "SHELL".into(),
command: None,
running: true,
attached: true,
created_at: None,
snapshot: None,
};
let mut app = app_with_tree();
if let Load::Loaded(agents) = &mut app.tree[0].projects[0].envs[0].agents {
agents[0].expanded = true;
agents[0].sessions = LoadSessions::Loaded(vec![remote_thread("same-vm-thread")]);
agents.push(Agent {
id: "ca_2".into(),
name: "quiet-harbor".into(),
status: "running".into(),
sessions: LoadSessions::Loaded(vec![remote_thread("other-vm-thread")]),
expanded: true,
});
}
let mut open = Session::for_test("ca_1", "nimble-otter").unwrap();
open.durable_name = "open-thread".into();
app.attach_session(open, "ca_1".into());
for width in [None, Some(58)] {
app.sidebar_width = width;
for (name, agent_id) in [("same-vm-thread", "ca_1"), ("other-vm-thread", "ca_2")] {
for on_edge in [false, true] {
app.focus = ManageFocus::Tree;
app.active = Some(0);
let cursor = app
.rows()
.iter()
.position(|row| row.label == format!("[S] {name}"))
.unwrap();
layout(&mut app, 140);
let tree = app.panes.tree;
app.on_mouse(MouseAction::Down, tree.x + 2, tree.y + cursor as u16);
app.on_mouse(MouseAction::Up, tree.x + 2, tree.y + cursor as u16);
assert_eq!(app.cursor, cursor);
layout(&mut app, 140);
let pane = app.panes.session;
let col = if on_edge {
app.panes.sidebar_divider.x + 1
} else {
pane.x + 3
};
let row = pane.y + 2;
assert_eq!(app.on_mouse(MouseAction::Down, col, row), None);
assert_eq!(app.on_mouse(MouseAction::Up, col, row), None);
assert_eq!(app.focus, ManageFocus::Session);
assert_eq!(app.active, None, "no fallback to the open thread");
assert_eq!(app.cursor, cursor);
assert_eq!(app.sessions.len(), 1, "the open session stays connected");
assert_eq!(app.sessions[0].durable_name, "open-thread");
let screen = draw(&app, 140, 40);
assert!(
screen.contains("not connected — its output isn't shown here"),
"{screen}"
);
assert!(
!screen.contains("devtools / nimble-otter / open-thread"),
"{screen}"
);
assert!(!last_drawn_line(&screen).contains("stop typing"));
assert_eq!(
app.on_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)),
None
);
assert_eq!(app.on_paste("do not send to the other VM".into()), None);
assert!(!app.sessions[0].input_within(std::time::Duration::from_secs(60)));
assert_eq!(app.on_mouse(MouseAction::Down, col, row), None);
assert_eq!(app.on_mouse(MouseAction::Up, col, row), None);
assert!(matches!(
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(Effect::Reattach { agent_id: id, session_name, .. })
if id == agent_id && session_name == name
));
app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.focus, ManageFocus::Tree);
app.active = Some(0);
app.on_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.focus, ManageFocus::Session);
assert_eq!(app.active, None);
app.on_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.focus, ManageFocus::Tree);
}
}
}
app.cursor = app
.rows()
.iter()
.position(|row| row.label == "[S] open-thread")
.unwrap();
layout(&mut app, 140);
let pane = app.panes.session;
app.on_mouse(MouseAction::Down, pane.x + 3, pane.y + 2);
app.on_mouse(MouseAction::Up, pane.x + 3, pane.y + 2);
assert_eq!(app.active, Some(0));
assert_eq!(app.focus, ManageFocus::Session);
}
#[test]
fn wheel_scrolls_the_visible_terminal_after_focus_and_resize_gestures() {
use crate::commands::cloud_agent::tui::{app::MouseAction, session::Session};
let mut app = app_with_tree();
let mut session = Session::for_test("ca_1", "nimble-otter").unwrap();
session.resize(12, 80);
for i in 0..100 {
session.send(format!("scroll-line-{i:03}\r\n").as_bytes());
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !session
.with_screen(|s| s.contents().contains("scroll-line-099"))
.unwrap_or(false)
{
assert!(
std::time::Instant::now() < deadline,
"fixture output must arrive"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
app.attach_session(session, "ca_1".into());
for width in [None, Some(58)] {
app.sidebar_width = width;
layout(&mut app, 140);
let pane = app.panes.session;
for focus in [ManageFocus::Tree, ManageFocus::Session] {
app.focus = focus;
app.sessions[0].scroll_by(isize::MIN);
let live = app.sessions[0].with_screen(|s| s.contents()).unwrap();
assert_eq!(
app.on_mouse(MouseAction::ScrollUp, pane.x + 4, pane.y + 4),
None
);
assert!(app.sessions[0].scrolled_back());
assert_ne!(app.sessions[0].with_screen(|s| s.contents()).unwrap(), live);
assert_eq!(app.focus, focus, "scrolling must not take focus");
app.on_mouse(MouseAction::ScrollDown, pane.x + 4, pane.y + 4);
assert!(!app.sessions[0].scrolled_back());
}
let divider = app.panes.sidebar_divider;
app.on_mouse(MouseAction::Down, divider.x, divider.y);
app.on_mouse(MouseAction::Drag, divider.x + 4, divider.y);
assert!(app.resizing_sidebar());
layout(&mut app, 140);
let pane = app.panes.session;
app.on_mouse(MouseAction::ScrollUp, pane.x + 4, pane.y + 4);
assert!(!app.resizing_sidebar());
assert_eq!(app.sidebar_width, width);
assert!(app.sessions[0].scrolled_back());
app.focus = ManageFocus::Tree;
app.active = None;
app.sessions[0].scroll_by(isize::MIN);
layout(&mut app, 140);
let pane = app.panes.session;
assert!(draw(&app, 140, 40).contains("scroll-line-099"));
app.on_mouse(MouseAction::ScrollUp, pane.x + 4, pane.y + 4);
assert!(app.sessions[0].scrolled_back());
assert_eq!(
app.active, None,
"scrolling does not change the active connection"
);
app.active = Some(0);
app.cursor = 0;
app.sessions[0].scroll_by(isize::MIN);
layout(&mut app, 140);
app.on_mouse(MouseAction::ScrollUp, pane.x + 4, pane.y + 4);
assert!(!app.sessions[0].scrolled_back());
app.cursor = app
.rows()
.iter()
.position(|row| matches!(row.kind, RowKind::Session(..)))
.unwrap();
}
}
#[test]
fn a_fresh_panel_click_recovers_from_a_lost_resize_release() {
use crate::commands::cloud_agent::tui::{app::MouseAction, session::Session};
let mut app = app_with_tree();
app.attach_session(
Session::for_test("ca_1", "nimble-otter").unwrap(),
"ca_1".into(),
);
for width in [None, Some(58)] {
for target in [ManageFocus::Tree, ManageFocus::Session] {
app.sidebar_width = width;
app.focus = if target == ManageFocus::Tree {
ManageFocus::Session
} else {
ManageFocus::Tree
};
layout(&mut app, 120);
let divider = app.panes.sidebar_divider;
app.on_mouse(MouseAction::Down, divider.x, divider.y);
app.on_mouse(MouseAction::Drag, divider.x + 5, divider.y);
assert!(app.resizing_sidebar());
assert_ne!(app.sidebar_width, width);
layout(&mut app, 120);
let pane = if target == ManageFocus::Tree {
app.panes.tree_outer
} else {
app.panes.session_outer
};
assert_eq!(app.on_mouse(MouseAction::Down, pane.x + 3, pane.y), None);
assert_eq!(app.on_mouse(MouseAction::Up, pane.x + 3, pane.y), None);
assert!(!app.resizing_sidebar());
assert_eq!(app.sidebar_width, width, "unfinished resize is canceled");
assert_eq!(app.focus, target);
}
}
}
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")
}
fn cells(app: &App, w: u16, h: u16) -> Vec<Vec<(String, Color)>> {
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| {
let cell = &buffer[(x, y)];
(cell.symbol().to_string(), cell.bg)
})
.collect()
})
.collect()
}
fn dialog_rows(grid: &[Vec<(String, Color)>], title: &str) -> (usize, usize, usize, usize) {
let top = grid
.iter()
.position(|row| {
let line: String = row.iter().map(|(s, _)| s.as_str()).collect();
line.contains('╭') && line.contains(title)
})
.unwrap_or_else(|| panic!("no dialog titled {title}"));
let left = grid[top].iter().position(|(s, _)| s == "╭").unwrap();
let right = grid[top].iter().rposition(|(s, _)| s == "╮").unwrap();
let bottom = (top + 1..grid.len())
.find(|y| grid[*y][left].0 == "╰")
.expect("a closed dialog");
(top, bottom, left, right)
}
#[test]
fn dialogs_are_filled_rather_than_transparent() {
let mut app = app_with_tree();
app.start_settings();
let grid = cells(&app, 100, 40);
let (top, bottom, left, right) = dialog_rows(&grid, "settings");
let surface = app.theme.surface;
for row in &grid[top..=bottom] {
for (symbol, bg) in &row[left..=right] {
assert!(
*bg == surface || *bg == app.theme.accent_dim,
"transparent cell {symbol:?} in the dialog: {bg:?}"
);
}
}
}
#[test]
fn the_page_keeps_clear_of_the_terminal_edges() {
for cursor in [0usize, 2] {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = cursor;
let grid = cells(&app, 100, 40);
let blank = |cells: &[(String, Color)]| cells.iter().all(|(s, _)| s == " ");
let h = grid.len();
for y in 0..PAGE_MARGIN_Y as usize {
assert!(blank(&grid[y]), "top margin row {y} has content");
assert!(blank(&grid[h - 1 - y]), "bottom margin row has content");
}
for (y, row) in grid.iter().enumerate() {
let w = row.len();
assert!(
blank(&row[..PAGE_MARGIN_X as usize]),
"left margin has content at row {y}"
);
assert!(
blank(&row[w - PAGE_MARGIN_X as usize..]),
"right margin has content at row {y}"
);
}
}
}
pub(super) fn last_drawn_line(out: &str) -> String {
out.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or_default()
.to_string()
}
#[test]
fn the_prompt_is_tall_with_its_room_outside() {
let app = app_with_tree();
let out = draw(&app, 100, 40);
let lines: Vec<&str> = out.lines().collect();
let top = lines
.iter()
.position(|l| l.contains(" Prompt "))
.expect("the prompt box");
let row: Vec<char> = lines[top].chars().collect();
let left = row.iter().position(|&c| c == '╭').expect("a top border");
let right = row.iter().rposition(|&c| c == '╮').expect("a top border");
let bottom = (top + 1..lines.len())
.find(|&y| lines[y].chars().nth(left) == Some('╰'))
.expect("the prompt's bottom border");
assert_eq!(bottom - top, 7, "six text rows inside the outline:\n{out}");
for y in [top - 1, bottom + 1] {
let inside: String = lines[y].chars().skip(left).take(right - left + 1).collect();
assert!(
inside.trim().is_empty(),
"row {y} should be the prompt's outside gap: {inside:?}"
);
}
}
#[test]
fn the_composer_matches_the_launcher_prompt_box() {
fn box_of(out: &str, title: &str) -> usize {
let lines: Vec<&str> = out.lines().collect();
let top = lines
.iter()
.position(|l| l.contains(title))
.unwrap_or_else(|| panic!("{title} not drawn"));
let row: Vec<char> = lines[top].chars().collect();
let left = row.iter().position(|&c| c == '╭').expect("a top border");
let bottom = (top + 1..lines.len())
.find(|&y| lines[y].chars().nth(left) == Some('╰'))
.expect("a closed box");
bottom - top + 1
}
let launcher = box_of(&draw(&app_with_tree(), 100, 40), "╭ Prompt");
let mut app = app_with_tree();
app.screen = Screen::ManagePrompt;
app.manage_prompt = Some(String::new());
let composer = box_of(&draw(&app, 100, 40), "╭ New Session");
assert_eq!(launcher, composer, "height of the two prompt boxes");
}
#[test]
fn the_welcome_title_sits_two_rows_above_the_prompt() {
let out = draw(&app_with_tree(), 100, 40);
let lines: Vec<&str> = out.lines().collect();
let title = lines
.iter()
.position(|l| l.contains("What should we build today?"))
.expect("the title is drawn");
let prompt = lines
.iter()
.position(|l| l.contains("╭ Prompt"))
.expect("the prompt box is drawn");
assert_eq!(
prompt,
title + 3,
"two rows between title and prompt:\n{out}"
);
}
#[test]
fn the_prompt_scrolls_to_the_caret_not_the_tail() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.prompt_focused = true;
app.cursor = 0;
app.prompt = "word ".repeat(200);
app.prompt_cursor = 0;
let out = draw(&app, 100, 30);
assert!(
out.contains('▏'),
"the caret stays in view at the front:\n{out}"
);
app.prompt_cursor = app.prompt.chars().count();
let out = draw(&app, 100, 30);
assert!(out.contains('▏'), "…and at the tail:\n{out}");
}
#[test]
fn the_loading_screen_echoes_the_prompt_in_its_own_box() {
use crate::commands::cloud_agent::tui::app::Loading;
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.loading = Loading {
active: true,
target: "devtools/production".into(),
harness: "claude".into(),
prompt: Some("ship the release notes".into()),
steps: Vec::new(),
tick: 0,
};
let out = draw(&app, 100, 40);
assert!(out.contains(" Prompt "), "the box keeps its name:\n{out}");
assert!(!out.contains(" Task "), "the old card is gone:\n{out}");
assert!(out.contains("ship the release notes"), "{out}");
let lines: Vec<&str> = out.lines().collect();
let top = lines.iter().position(|l| l.contains(" Prompt ")).unwrap();
let col = lines[top].chars().position(|c| c == '╭').unwrap();
let bottom = (top + 1..lines.len())
.find(|&y| lines[y].chars().nth(col) == Some('╰'))
.expect("a closed box");
let right = lines[top]
.chars()
.collect::<Vec<_>>()
.iter()
.rposition(|&c| c == '╮')
.expect("a top border");
let below: String = lines[bottom + 1]
.chars()
.skip(col)
.take(right - col + 1)
.collect();
assert!(
below.trim().is_empty(),
"the row under the prompt box should be clear: {below:?}"
);
}
#[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_launcher_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 launcher footer");
assert!(footer.contains("enter"), "{footer}");
assert!(footer.contains("settings"), "{footer}");
assert!(
!footer.contains("theme"),
"the theme moved onto the settings card: {footer}"
);
assert!(!footer.contains("target"), "{footer}");
assert!(!footer.contains("menu"), "the arrow hint is gone: {footer}");
assert!(!footer.contains(" · "), "{footer}");
}
#[test]
fn the_prompt_box_explains_itself_on_shell() {
let mut app = app_with_tree();
app.prompt = "fix the tests".into();
while app.harness_name() != "shell" {
app.on_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::BackTab,
crossterm::event::KeyModifiers::SHIFT,
));
}
let out = draw(&app, 100, 40);
assert!(out.contains("A plain shell on the agent"), "{out}");
assert!(
!out.contains("fix the tests"),
"the hidden draft must not show through: {out}"
);
let footer = out
.lines()
.find(|l| l.contains("shell") && l.contains("shift+tab"))
.expect("the prompt box footer names the selection");
assert!(footer.contains("shell"), "{footer}");
}
#[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 the_target_shortcut_sits_on_its_own_line() {
let app = app_with_tree();
let out = draw(&app, 100, 40);
let target = out
.lines()
.find(|l| l.contains("Target Project"))
.expect("the target indicator");
let chord_at = target.find("⌥t").expect("the chord badge: {target}");
let label_at = target.find("Target Project").unwrap();
assert!(
chord_at < label_at,
"the chord badge comes before the label: {target}"
);
let footer = out
.lines()
.rfind(|l| l.contains("settings"))
.expect("the menu footer");
assert!(
!footer.contains("⌥t"),
"the chord moved out of the footer: {footer}"
);
}
#[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_prompt_box_matches_the_drawn_rows() {
use crate::commands::cloud_agent::tui::app::PaneRects;
let app = app_with_tree();
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();
let prompt = lines
.iter()
.position(|l| l.contains("╭ Prompt"))
.expect("the prompt box");
assert_eq!(rects.prompt.y as usize, prompt);
}
#[test]
fn a_deeply_scrolled_pane_draws_old_history() {
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 session = app.sessions.last_mut().expect("just attached");
session.resize(6, 40);
for i in 0..80 {
session.send(format!("line-{i}\r\n").as_bytes());
}
for _ in 0..100 {
std::thread::sleep(std::time::Duration::from_millis(20));
let seen = session
.with_screen(|screen| screen.contents().contains("line-79"))
.unwrap_or(false);
if seen {
break;
}
}
session.scroll_by(isize::MAX);
assert!(session.scrolled_back());
let out = draw(&app, 92, 20);
assert!(
out.contains("line-0"),
"the top of history should be on screen:\n{out}"
);
assert!(
!out.contains("line-79"),
"the tail should be scrolled out of view:\n{out}"
);
assert!(
out.contains("scrolled back"),
"the pane should say where it is:\n{out}"
);
}
#[test]
fn the_session_pty_matches_the_drawn_pane() {
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 mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
let mut rects = crate::commands::cloud_agent::tui::app::PaneRects::default();
terminal
.draw(|f| {
let (r, _) = render_with_layout(&app, f);
rects = r;
})
.unwrap();
let (rows, cols) =
session_pane_size(Some(ratatui::layout::Size::new(100, 40)), false, None).unwrap();
assert_eq!(
(cols, rows),
(rects.session.w, rects.session.h),
"the PTY must be exactly the pane the emulator is drawn into"
);
}
#[test]
fn hidden_tabs_leave_the_maximized_header_bare() {
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.maximized = true;
let header = |out: &str| {
out.lines()
.find(|l| l.contains("RAILWAY CLOUD-AGENTS"))
.unwrap_or_default()
.to_string()
};
let out = draw(&app, 100, 30);
assert!(header(&out).contains(" 1 "), "tabs show by default:\n{out}");
app.hide_tabs = true;
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut rects = crate::commands::cloud_agent::tui::app::PaneRects::default();
terminal
.draw(|f| {
let (r, _) = render_with_layout(&app, f);
rects = r;
})
.unwrap();
let out = draw(&app, 100, 30);
assert!(
!header(&out).contains(" 1 "),
"no tab row when hidden:\n{out}"
);
assert_eq!(rects.tabs[0].w, 0, "nothing stale to click");
}
#[test]
fn an_error_toast_centers_over_the_session_pane() {
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_error("Launch failed: boom");
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut rects = crate::commands::cloud_agent::tui::app::PaneRects::default();
terminal
.draw(|f| {
let (r, _) = render_with_layout(&app, f);
rects = r;
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let out = (0..30)
.map(|y| {
(0..100)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let lines: Vec<&str> = out.lines().collect();
let row = lines
.iter()
.position(|l| l.contains("✕"))
.expect("the error toast");
assert!(row > lines.len() * 2 / 3, "near the bottom: {row}");
let line: Vec<char> = lines[row].chars().collect();
let needle: Vec<char> = "Launch failed: boom".chars().collect();
let start = line
.windows(needle.len())
.position(|w| w == needle.as_slice())
.expect("the reason");
let end = start + needle.len();
assert!(
start > rects.session.x as usize,
"inside the session pane: {start}"
);
let pane_mid = rects.session.x as usize + rects.session.w as usize / 2;
let toast_mid = (start + end) / 2;
assert!(
toast_mid.abs_diff(pane_mid) <= 3,
"centered in the pane: toast mid {toast_mid}, pane mid {pane_mid}\n{out}"
);
}
#[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}");
let strip = last_drawn_line(&out);
assert!(
strip.contains("keys"),
"the key strip is untouched: {strip}"
);
let closed = (row + 1..lines.len())
.find(|y| lines[*y].contains("╰"))
.expect("the toast's bottom border");
assert!(
closed < lines.len() - 1,
"the toast should close above the key strip: {}",
lines[closed]
);
assert!(
lines[row + 1..=closed]
.iter()
.all(|l| !l.contains("Copied")),
"the text is inside the box only once"
);
}
#[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.trim_start().starts_with("╰"))
.expect("the pane's bottom border");
assert!(!border.contains("to leave"), "{border}");
assert!(
last_drawn_line(&out).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("threads"), "the tree is there first");
app.maximized = true;
let out = draw(&app, 100, 30);
assert!(!out.contains(" threads "), "the tree is gone:\n{out}");
assert!(!out.contains("+ New Agent"), "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(PAGE_MARGIN_X as usize),
"the pane starts at the page's 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, None).unwrap();
let (_, full) = session_pane_size(size, true, None).unwrap();
assert_eq!(split, 100 - PAGE_MARGIN_X * 2 - TREE_W - 2);
assert_eq!(full, 100 - PAGE_MARGIN_X * 2 - 2);
let narrow = Some(ratatui::layout::Size {
width: 50,
height: 20,
});
assert!(session_pane_size(narrow, false, None).is_none());
assert!(session_pane_size(narrow, true, None).is_some());
}
#[test]
fn a_very_narrow_launcher_keeps_the_prompt() {
let app = app_with_tree();
let out = draw(&app, 46, 40);
assert!(out.contains("Prompt"), "{out}");
assert!(
out.lines().all(|l| l.trim_end().chars().count() <= 46),
"nothing runs off the edge:\n{out}"
);
}
#[test]
fn the_launcher_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("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 session_discovery_never_replaces_the_machine_status_icon() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "nimble-otter")
.unwrap();
if let Load::Loaded(agents) = &mut app.tree[0].projects[0].envs[0].agents {
agents[0].sessions = LoadSessions::Loading;
}
app.loading.tick = 0;
assert!(draw(&app, 100, 30).contains("● nimble-otter"));
app.tick();
let out = draw(&app, 100, 30);
assert!(out.contains("● nimble-otter"));
assert!(
out.contains("running"),
"the detail pane retains the VM status: {out}"
);
app.sessions_loaded((0, 0, 0, 0), "ca_1", Err("temporary failure".into()));
let out = draw(&app, 100, 30);
assert!(out.contains("● nimble-otter"));
assert!(
out.contains("couldn't load sessions"),
"failure details remain available: {out}"
);
app.sessions_loaded((0, 0, 0, 0), "ca_1", Ok(Vec::new()));
assert!(draw(&app, 100, 30).contains("● nimble-otter"));
}
#[test]
fn manage_drops_the_detail_pane_when_narrow() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
app.cursor = 2;
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_tree() {
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}");
}
#[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(),
base: Default::default(),
});
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 a_collapsed_launch_gives_the_wait_the_whole_window() {
let mut app = app_with_tree();
app.maximized = true;
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: None,
label: "devtools/production".into(),
base: Default::default(),
});
app.loading_step("Creating a cloud agent".into());
let out = draw(&app, 100, 30);
assert!(out.contains("Creating a cloud agent"), "steps:\n{out}");
assert!(
!out.contains("cloud agents "),
"the tree pane's title should be gone:\n{out}"
);
assert!(out.contains("restore the tree"), "footer:\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 = last_drawn_line(&out);
let footer = footer.as_str();
assert!(footer.contains("connect"), "{footer}");
assert!(footer.contains("new VM"), "{footer}");
assert!(footer.contains("delete"), "{footer}");
assert!(footer.contains("save bootstrap"), "{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 = last_drawn_line(&draw(&app, 120, 30));
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.tree[0].projects.push(ProjectNode {
id: "proj_2".into(),
name: "sandbox".into(),
expanded: false,
envs: vec![EnvNode {
id: "env_sand".into(),
name: "production".into(),
expanded: false,
agents: Load::NotLoaded,
}],
});
app.others_expanded = Some(true);
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "sandbox")
.unwrap();
let footer = last_drawn_line(&draw(&app, 120, 30));
assert!(footer.contains("new VM"), "{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, 34);
assert!(out.contains("keys"));
assert!(out.contains("refresh"), "{out}");
assert!(out.contains("⌥esc"), "{out}");
assert!(out.contains("any key closes"));
}
#[test]
fn the_launcher_stays_up_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,
created_at: None,
snapshot: None,
},
]);
}
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 = 0;
let out = draw(&app, 110, 30);
assert!(
out.contains("Prompt"),
"the launcher holds the pane:\n{out}"
);
assert!(
!out.contains("╭ devtools / nimble-otter / claude-one"),
"the running pane must not take over:\n{out}"
);
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "[S] claude-one")
.unwrap();
let out = draw(&app, 110, 30);
assert!(
out.contains("devtools / nimble-otter / claude-one"),
"the session pane's title:\n{out}"
);
}
#[test]
fn a_dropped_pane_wears_a_banner_on_top() {
let mut app = app_with_tree();
app.screen = Screen::Manage;
let pane =
crate::commands::cloud_agent::tui::session::Session::for_test("ca_1", "nimble-otter")
.unwrap();
app.sessions = vec![pane];
app.active = Some(0);
app.focus = ManageFocus::Session;
app.sessions[0].end_dropped_for_test();
let out = draw(&app, 110, 30);
assert!(
out.contains("✕ disconnected — press r to reconnect · x closes"),
"the banner names the recovery keys:\n{out}"
);
let title_line = out
.lines()
.position(|l| l.contains("devtools / nimble-otter"))
.unwrap();
let banner_line = out
.lines()
.position(|l| l.contains("✕ disconnected"))
.unwrap();
assert_eq!(
banner_line,
title_line + 1,
"the banner belongs at the top of the pane:\n{out}"
);
app.focus = ManageFocus::Tree;
if let Load::Loaded(agents) = &mut app.tree[0].projects[0].envs[0].agents {
agents[0].sessions = LoadSessions::Loaded(vec![
crate::commands::cloud_agent::tui::app::ConsoleSession {
name: "test".into(),
kind: "SHELL".into(),
command: None,
running: true,
attached: false,
created_at: None,
snapshot: None,
},
]);
}
app.cursor = app
.rows()
.iter()
.position(|r| r.label == "[S] test")
.unwrap();
let out = draw(&app, 110, 30);
assert!(
out.contains("✕ disconnected — click here (or enter on its row) to reconnect"),
"the unfocused banner points at the mouse and the row:\n{out}"
);
}
#[test]
fn a_disconnected_session_says_so() {
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,
created_at: None,
snapshot: None,
},
crate::commands::cloud_agent::tui::app::ConsoleSession {
name: "claude-two".into(),
kind: "SHELL".into(),
command: None,
running: true,
attached: false,
created_at: None,
snapshot: None,
},
]);
}
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 == "[S] claude-two")
.unwrap();
let out = draw(&app, 110, 30);
assert!(
!out.contains("╭ devtools / nimble-otter / claude-one"),
"the other session's pane must not linger:\n{out}"
);
assert!(
out.contains("not connected"),
"the card says the session is disconnected:\n{out}"
);
assert!(
out.contains("enter / double-click connects"),
"the card says how to get it back:\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("what changed"),
"the end of the prompt should be visible:\n{out}"
);
}
#[test]
fn emulator_attributes_reach_the_pane() {
let mut parser = vt100::Parser::new(4, 40, 0);
parser.process(b"plain \x1b[2mghost\x1b[22m \x1b[3mslant\x1b[23m \x1b[1mloud\x1b[22m");
let lines = screen_lines(parser.screen(), false);
let style_of = |word: &str| {
lines[0]
.spans
.iter()
.find(|span| span.content.contains(word))
.unwrap_or_else(|| panic!("{word} is on screen"))
.style
};
assert!(style_of("ghost").add_modifier.contains(Modifier::DIM));
assert!(style_of("slant").add_modifier.contains(Modifier::ITALIC));
assert!(style_of("loud").add_modifier.contains(Modifier::BOLD));
assert!(style_of("plain").add_modifier.is_empty());
}
#[test]
fn codex_shaded_rows_keep_truecolor_and_blank_cell_backgrounds() {
use ratatui::widgets::Widget;
for (shade, rgb) in [("48;48;48", (48, 48, 48)), ("245;245;245", (245, 245, 245))] {
let mut parser = vt100::Parser::new(3, 20, 0);
parser.process(
format!("\x1b[48;2;{shade}m\x1b[2K plan\x1b[0m\r\n\x1b[38;2;0;95;135maccent\x1b[0m plain")
.as_bytes(),
);
let area = Rect::new(0, 0, 20, 3);
let mut buffer = ratatui::buffer::Buffer::empty(area);
Paragraph::new(screen_lines(parser.screen(), false)).render(area, &mut buffer);
for col in 0..20 {
assert_eq!(buffer[(col, 0)].bg, Color::Rgb(rgb.0, rgb.1, rgb.2));
assert_eq!(buffer[(col, 1)].bg, Color::Reset);
}
assert_eq!(buffer[(10, 0)].symbol(), " ");
assert_eq!(buffer[(0, 1)].fg, Color::Rgb(0, 95, 135));
assert_eq!(buffer[(7, 1)].fg, Color::Reset);
}
}
#[test]
fn wide_characters_keep_their_columns() {
let mut parser = vt100::Parser::new(2, 10, 0);
parser.process("\u{65e5}x".as_bytes());
let lines = screen_lines(parser.screen(), false);
let text: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert_eq!(text, "\u{65e5}x ");
}
#[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(),
base: Default::default(),
});
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[0].envs.push(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::Target;
w.workspaces[0].projects[0].expanded = true;
}
let out = draw(&app, 100, 30);
let lines: Vec<&str> = out.lines().collect();
let first = lines
.iter()
.position(|l| l.contains("production"))
.expect("the environment row");
assert!(
lines[first + 1].contains("staging"),
"rows should be adjacent:\n{out}"
);
}
#[test]
fn the_settings_card_shows_values_in_place() {
let mut app = app_with_tree();
app.skills_source = Some("claude".into());
app.skills_enabled = true;
app.start_settings();
let out = draw(&app, 100, 40);
assert!(out.contains("Cloud agent settings"), "{out}");
assert!(
out.contains("‹ claude ›"),
"the highlighted row cycles in place:\n{out}"
);
assert!(out.contains("on · claude"), "{out}");
assert!(out.contains("Railway"), "the theme's label:\n{out}");
assert!(out.contains("Run first-time setup again"), "{out}");
assert!(
out.contains("not set"),
"no default project reads as such:\n{out}"
);
let lines: Vec<_> = out.lines().collect();
let positions: Vec<_> = [
"Coding agent",
"Default project",
"Skills sync",
"Theme",
"Full-screen tabs",
"Run first-time setup again",
]
.iter()
.map(|label| lines.iter().position(|line| line.contains(label)).unwrap())
.collect();
assert!(
positions.windows(2).all(|pair| pair[1] == pair[0] + 1),
"one line per setting: {out}"
);
assert!(!out.contains("Previews as you cycle"));
assert!(!out.contains("Copied to the agent at launch"));
assert!(!out.contains("Where new cloud agents are created"));
}
#[test]
fn the_settings_project_picker_is_the_setup_question() {
let mut app = app_with_tree();
app.start_settings();
if let Some(settings) = app.settings.as_mut() {
settings.down(); settings.select(); }
let out = draw(&app, 100, 40);
assert!(out.contains("Where should agents live?"), "{out}");
assert!(out.contains("devtools (production)"), "{out}");
assert!(out.contains("Decide later"), "{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"));
}
#[test]
fn the_ssh_gate_card_lays_out_key_and_answers() {
use crate::commands::cloud_agent::tui::app::{SshGate, SshKeyOffer};
let mut app = app_with_tree();
app.ssh_gate = Some(SshGate {
offer: SshKeyOffer {
name: "raildesk-deploy".into(),
fingerprint: "SHA256:hlDEs7CV5clc1lMfsMxr/CPeuKuJNn9hJxjsy1e9zLc".into(),
public_key: "ssh-ed25519 AAAA test".into(),
},
then: None,
});
let out = draw(&app, 80, 30);
assert!(out.contains("Register your SSH key with Railway?"), "{out}");
assert!(out.contains(" y "), "{out}");
assert!(out.contains("Yes — register this key"), "{out}");
assert!(out.contains("No, not now"), "{out}");
let name_line = out
.lines()
.position(|l| l.contains("raildesk-deploy"))
.expect("key name shown");
let fp_line = out
.lines()
.position(|l| l.contains("SHA256:hlDEs7CV5clc1lMfsMxr/CPeuKuJNn9hJxjsy1e9zLc"))
.expect("full fingerprint shown, unwrapped");
assert_eq!(fp_line, name_line + 1, "fingerprint sits under the name");
let col = |needle: &str| {
out.lines()
.find_map(|l| l.find(needle))
.unwrap_or_else(|| panic!("{needle} not shown"))
};
assert!(
col("Yes — register this key") > col("raildesk-deploy"),
"the answers should sit centered, right of the left-aligned copy"
);
}
}