use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Padding, Paragraph},
Frame,
};
use crate::app::{AppState, AppStatus, AutocompleteItem, ChatMessage};
use crate::config::Theme;
const STELLAR_YELLOW: Color = Color::Rgb(253, 218, 36); const STELLAR_BLACK: Color = Color::Rgb(15, 15, 15); const STELLAR_WHITE: Color = Color::Rgb(246, 247, 248); const STELLAR_LAVENDER: Color = Color::Rgb(183, 172, 232); const STELLAR_TEAL: Color = Color::Rgb(0, 167, 181); const STELLAR_NAVY: Color = Color::Rgb(0, 46, 93); const STELLAR_GREY_DARK_BG: Color = Color::Rgb(138, 134, 124);
const STELLAR_GREY_LIGHT_BG: Color = Color::Rgb(107, 103, 94);
pub struct ColorPalette {
pub fg: Color,
pub dim: Color,
pub accent: Color,
pub network: Color,
pub ok: Color,
pub err: Color,
}
impl ColorPalette {
pub fn from_theme(theme: &Theme) -> Self {
match theme {
Theme::Dark => Self {
fg: STELLAR_WHITE,
dim: STELLAR_GREY_DARK_BG,
accent: STELLAR_YELLOW,
network: STELLAR_LAVENDER,
ok: STELLAR_TEAL,
err: Color::Red,
},
Theme::Light => Self {
fg: STELLAR_BLACK,
dim: STELLAR_GREY_LIGHT_BG,
accent: STELLAR_NAVY,
network: STELLAR_TEAL,
ok: STELLAR_TEAL,
err: Color::Red,
},
}
}
}
const USER_GLYPH: &str = "› ";
const STEP_GLYPH: &str = "⏺ ";
const SUBSTEP_GLYPH: &str = "⎿ ";
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const INPUT_FRAME_HEIGHT: u16 = 3;
const APPROVAL_FRAME_HEIGHT: u16 = 4;
fn frame_block(color: Color) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
.padding(Padding::horizontal(1))
}
pub fn render(frame: &mut Frame, state: &mut AppState, theme: &Theme) {
let palette = ColorPalette::from_theme(theme);
let input_height = if state.pending_approval.is_some() {
APPROVAL_FRAME_HEIGHT
} else if state.autocomplete_active && !state.autocomplete_matches.is_empty() {
INPUT_FRAME_HEIGHT + autocomplete_popup_height(state.autocomplete_matches.len())
} else {
INPUT_FRAME_HEIGHT
};
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(1),
])
.split(frame.area());
let body = frame_block(palette.dim);
let body_inner = body.inner(main_chunks[0]);
frame.render_widget(body, main_chunks[0]);
if has_conversation(state) {
render_chat(frame, state, body_inner, &palette);
} else {
render_welcome(frame, state, body_inner, &palette);
}
render_input(frame, state, main_chunks[1], &palette);
render_statusline(frame, state, main_chunks[2], &palette);
if state.palette_open {
render_palette(frame, state, &palette);
}
}
fn has_conversation(state: &AppState) -> bool {
!state.messages.is_empty() || !state.execution_steps.is_empty()
}
const STELLAR_MARK: [&str; 3] = [" ╱╱ ", "╱╱╱ ", " ╱╱ "];
fn render_welcome(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let dim = Style::default().fg(palette.dim);
let mark = Style::default().fg(palette.accent);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(format!(" {} ", STELLAR_MARK[0]), mark),
Span::styled(
"procyon",
Style::default().fg(palette.fg).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(format!(" {} ", STELLAR_MARK[1]), mark),
Span::styled("Stellar development harness", dim),
]),
Line::from(Span::styled(format!(" {}", STELLAR_MARK[2]), mark)),
Line::from(""),
Line::from(vec![
Span::styled(" ", dim),
Span::styled(state.cwd_label.clone(), dim),
]),
Line::from(vec![
Span::styled(" ", dim),
Span::styled(
state.active_network.clone(),
Style::default().fg(palette.network),
),
Span::styled(
match state.project_name.as_str() {
"No project" => String::new(),
name => format!(" · {}", name),
},
dim,
),
Span::styled(
{
let spent = 8
+ state.active_network.chars().count()
+ match state.project_name.as_str() {
"No project" => 0,
name => name.chars().count() + 3,
}
+ state.active_provider.chars().count()
+ 4;
let room = (area.width as usize).saturating_sub(spent);
format!(
" · {} {}",
state.active_provider,
truncate_to(&state.active_model, room)
)
},
dim,
),
]),
Line::from(""),
Line::from(Span::styled(
match area.width {
w if w >= 78 => {
" Type / for commands · ctrl+k for the palette · ? for shortcuts"
}
w if w >= 50 => " / commands · ctrl+k palette · ? shortcuts",
_ => " / · ctrl+k · ?",
},
dim,
)),
];
frame.render_widget(Paragraph::new(lines), area);
}
struct Segment {
priority: u8,
text: String,
style: Style,
}
const MODEL_BUDGET: usize = 26;
const NAME_BUDGET: usize = 20;
fn risk_mode(state: &AppState) -> &'static str {
if state.mainnet_allowed {
"armed"
} else {
"guarded"
}
}
fn render_statusline(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let dim = Style::default().fg(palette.dim);
let mut segments: Vec<Segment> = Vec::new();
let mainnet = state.active_network == "mainnet";
segments.push(Segment {
priority: 0,
text: if mainnet && !state.mainnet_allowed {
format!("{} (read-only)", state.active_network)
} else {
state.active_network.clone()
},
style: if mainnet {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.network)
},
});
let armed = state.mainnet_allowed;
segments.push(Segment {
priority: 1,
text: risk_mode(state).to_string(),
style: if armed {
Style::default()
.fg(palette.err)
.add_modifier(Modifier::BOLD)
} else {
dim
},
});
segments.push(Segment {
priority: 3,
text: absent_as(&state.active_account, "no account"),
style: dim,
});
segments.push(Segment {
priority: 4,
text: truncate_to(&absent_as(&state.project_name, "no project"), NAME_BUDGET),
style: dim,
});
if let Some(contract) = &state.active_contract {
segments.push(Segment {
priority: 2,
text: abbreviate(contract),
style: dim,
});
}
segments.push(Segment {
priority: 3,
text: truncate_to(
&format!("{} {}", state.active_provider, state.active_model),
MODEL_BUDGET,
),
style: dim,
});
if let Some((used, window)) = state.context_usage {
let pressured = window > 0 && used * 5 >= window * 4;
segments.push(Segment {
priority: 5,
text: format!("ctx {}", crate::app::context_meter(used, window)),
style: if pressured {
Style::default().fg(palette.err)
} else {
dim
},
});
}
match state.status {
AppStatus::Working => {
let spinner = SPINNER_FRAMES[state.spinner_frame % SPINNER_FRAMES.len()];
let activity = state
.current_activity
.as_deref()
.map(crate::app::activity_label)
.unwrap_or("Working...");
segments.push(Segment {
priority: 0,
text: format!("{} {}", spinner, activity),
style: Style::default().fg(palette.accent),
});
}
AppStatus::NeedsCredential => segments.push(Segment {
priority: 0,
text: "● needs login".to_string(),
style: Style::default().fg(palette.err),
}),
AppStatus::Ready => {}
}
let width = area.width as usize;
let working = matches!(state.status, AppStatus::Working);
let hint = match (working, width >= 60) {
(true, true) => "esc interrupt · ctrl+k ",
(true, false) => "esc interrupt ",
(false, true) => "? shortcuts · ctrl+k ",
(false, false) => "ctrl+k ",
};
const INDENT: usize = 2;
const GAP: usize = 2;
let budget = width.saturating_sub(INDENT);
let mut show_hint = true;
loop {
let needed = laid_out_width(&segments)
+ if show_hint {
hint.chars().count() + GAP
} else {
0
};
if needed <= budget {
break;
}
match segments
.iter()
.enumerate()
.filter(|(_, s)| s.priority > 0)
.max_by_key(|(i, s)| (s.priority, *i))
.map(|(i, _)| i)
{
Some(index) => {
segments.remove(index);
}
None if show_hint => show_hint = false,
None => break,
}
}
let mut spans = vec![Span::raw(" ".repeat(INDENT))];
for (index, segment) in segments.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(" · ", dim));
}
spans.push(Span::styled(segment.text.clone(), segment.style));
}
let mut used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if used > width {
let mut left = width;
for span in spans.iter_mut() {
let len = span.content.chars().count();
if len <= left {
left -= len;
} else {
span.content = truncate_to(span.content.as_ref(), left).into();
left = 0;
}
}
used = width;
}
if show_hint && used + hint.chars().count() + GAP <= width {
spans.push(Span::raw(" ".repeat(width - used - hint.chars().count())));
spans.push(Span::styled(hint, dim));
}
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn laid_out_width(segments: &[Segment]) -> usize {
let text: usize = segments.iter().map(|s| s.text.chars().count()).sum();
text + 3 * segments.len().saturating_sub(1)
}
fn absent_as(value: &str, absent: &'static str) -> String {
match value.trim() {
"" | "None" | "none" | "No project" => absent.to_string(),
other => other.to_string(),
}
}
fn abbreviate(id: &str) -> String {
let chars: Vec<char> = id.chars().collect();
if chars.len() <= 12 {
return id.to_string();
}
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{}…{}", head, tail)
}
fn render_palette(frame: &mut Frame, state: &AppState, palette: &ColorPalette) {
let area = frame.area();
let width = (area.width.saturating_sub(10)).clamp(40, 70);
let height = (state.palette_matches.len().min(8) as u16 + 4).min(area.height.saturating_sub(4));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let popup = Rect::new(x, y, width, height);
let block = frame_block(palette.dim).title(" commands ");
let inner = block.inner(popup);
let gutter = Rect::new(
popup.x.saturating_sub(1),
popup.y,
(popup.width + 2).min(area.width - popup.x.saturating_sub(1)),
popup.height,
);
frame.render_widget(ratatui::widgets::Clear, gutter);
frame.render_widget(block, popup);
let input_line = Line::from(vec![
Span::styled(USER_GLYPH, Style::default().fg(palette.accent)),
Span::styled(
state.palette_input.as_str(),
Style::default().fg(palette.fg),
),
]);
let input_area = Rect::new(inner.x, inner.y, inner.width, 1);
frame.render_widget(Paragraph::new(input_line), input_area);
let list_y = inner.y + 2;
let list_height = inner.height.saturating_sub(2) as usize;
let offset = scroll_offset(
state.palette_selected,
state.palette_matches.len(),
list_height,
);
let lines: Vec<Line<'static>> = state
.palette_matches
.iter()
.enumerate()
.skip(offset)
.take(list_height)
.map(|(idx, item)| {
let style = if idx == state.palette_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
Line::from(vec![
Span::styled(format!(" {:<16} ", item.value), style),
Span::styled(item.description.clone(), Style::default().fg(palette.dim)),
])
})
.collect();
let list_area = Rect::new(inner.x, list_y, inner.width, list_height as u16);
frame.render_widget(Paragraph::new(lines), list_area);
let cx = inner.x + 2 + state.palette_cursor as u16;
let cy = inner.y;
if cx < inner.x + inner.width {
frame.set_cursor_position((cx, cy));
}
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in text.split(' ') {
let word_width = word.chars().count();
if word_width > width {
if current_width > 0 {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(width) {
lines.push(chunk.iter().collect());
}
if let Some(last) = lines.pop() {
current_width = last.chars().count();
current = last;
}
continue;
}
let needed = if current_width == 0 {
word_width
} else {
current_width + 1 + word_width
};
if needed > width {
lines.push(std::mem::take(&mut current));
current.push_str(word);
current_width = word_width;
} else {
if current_width > 0 {
current.push(' ');
}
current.push_str(word);
current_width = needed;
}
}
lines.push(current);
lines
}
fn message_lines(msg: &ChatMessage, palette: &ColorPalette, width: usize) -> Vec<Line<'static>> {
let (lead, lead_style, body_style) = match msg {
ChatMessage::User(_) => (
USER_GLYPH,
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
Style::default().fg(palette.fg),
),
ChatMessage::Agent(_) => ("", Style::default(), Style::default().fg(palette.fg)),
ChatMessage::System(_) => (
STEP_GLYPH,
Style::default().fg(palette.dim),
Style::default().fg(palette.dim),
),
};
let content = match msg {
ChatMessage::User(text) | ChatMessage::Agent(text) | ChatMessage::System(text) => {
text.as_str()
}
};
let lead_width = lead.chars().count();
let indent = " ".repeat(lead_width);
let mut out = Vec::new();
for logical in content.split('\n') {
let budget = width.saturating_sub(lead_width).max(1);
for (wrapped_index, piece) in wrap_text(logical, budget).into_iter().enumerate() {
let is_first = out.is_empty() && wrapped_index == 0;
if is_first && !lead.is_empty() {
out.push(Line::from(vec![
Span::styled(lead, lead_style),
Span::styled(piece, body_style),
]));
} else if lead.is_empty() {
out.push(Line::from(Span::styled(piece, body_style)));
} else {
out.push(Line::from(vec![
Span::raw(indent.clone()),
Span::styled(piece, body_style),
]));
}
}
}
out
}
fn render_chat(frame: &mut Frame, state: &mut AppState, area: Rect, palette: &ColorPalette) {
let inner_width = area.width.saturating_sub(1) as usize;
let viewport = area.height as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
let mut steps = state.execution_steps.iter().peekable();
for (index, msg) in state.messages.iter().enumerate() {
while steps.peek().is_some_and(|step| step.after <= index) {
let step = steps.next().expect("peeked");
lines.extend(execution_step_lines(step, palette, inner_width));
}
lines.extend(message_lines(msg, palette, inner_width));
}
for step in steps {
lines.extend(execution_step_lines(step, palette, inner_width));
}
let max_scroll = lines.len().saturating_sub(viewport);
let offset_from_top = state.resolve_scroll(max_scroll);
frame.render_widget(
Paragraph::new(lines).scroll((offset_from_top as u16, 0)),
area,
);
let below = max_scroll - offset_from_top;
if !state.is_following_chat() && below > 0 && area.height > 0 {
let marker = format!(" ↓ {} more ", below);
let w = (marker.chars().count() as u16).min(area.width);
let row = Rect::new(
area.x + area.width.saturating_sub(w),
area.y + area.height - 1,
w,
1,
);
frame.render_widget(ratatui::widgets::Clear, row);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
marker,
Style::default().fg(palette.dim),
))),
row,
);
}
}
fn execution_step_lines(
step: &crate::app::ExecutionStep,
palette: &ColorPalette,
width: usize,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
{
let is_substep = step.label.to_lowercase().starts_with("using tool:");
let color = match step.state {
crate::app::ExecutionStepState::Done => palette.ok,
crate::app::ExecutionStepState::Failed => palette.err,
crate::app::ExecutionStepState::Running => palette.accent,
crate::app::ExecutionStepState::Waiting => palette.accent,
};
let (glyph, indent) = if is_substep {
(SUBSTEP_GLYPH, " ")
} else {
(STEP_GLYPH, "")
};
let style = Style::default().fg(color);
let lead_width = indent.chars().count() + glyph.chars().count();
let budget = width.saturating_sub(lead_width).max(10);
let pretty = pretty_execution_label(&step.label, &step.state);
let body = if is_substep {
Style::default().fg(palette.dim)
} else {
Style::default().fg(palette.fg)
};
for (i, piece) in wrap_text(&pretty, budget).into_iter().enumerate() {
if i == 0 {
out.push(Line::from(vec![
Span::raw(indent),
Span::styled(glyph, style),
Span::styled(piece, body),
]));
} else {
out.push(Line::from(vec![
Span::raw(" ".repeat(lead_width)),
Span::styled(piece, body),
]));
}
}
}
out
}
fn pretty_execution_label(raw: &str, state: &crate::app::ExecutionStepState) -> String {
use crate::app::ExecutionStepState as S;
let lower = raw.to_lowercase();
if lower.contains("thinking") {
return match state {
S::Done => "Thought".to_string(),
_ => "Thinking...".to_string(),
};
}
if lower.starts_with("using tool:") {
let tool = raw.split(':').nth(1).unwrap_or("").trim();
let suffix = match state {
S::Running => friendly_tool_desc(tool),
S::Waiting => "waiting for you",
S::Done => "done",
S::Failed => "failed",
};
return format!("{} — {}", tool, suffix);
}
if lower.starts_with("mcp ") {
return raw.to_string();
}
raw.to_string()
}
fn friendly_tool_desc(tool: &str) -> &'static str {
match tool {
"caatinga_build" => "building contract",
"caatinga_deploy" => "deploying to network",
"caatinga_doctor" => "checking environment",
"caatinga_invoke" => "invoking contract",
"caatinga_read" => "reading contract",
"stellar_invoke" => "invoking via CLI",
"read_file" => "reading file",
"write_file" => "writing file",
"edit_file" => "editing file",
"grep" => "searching code",
"glob" => "locating files",
"list_dir" => "listing directory",
"account_create" => "creating account",
"account_balance" => "checking balance",
_ if tool.starts_with("raven__") => "searching Stellar Docs",
_ => "executing",
}
}
fn truncate_to(text: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut out: String = text.chars().take(max_chars.saturating_sub(1)).collect();
out.push('…');
out
}
const AUTOCOMPLETE_MAX_VISIBLE: usize = 5;
const AUTOCOMPLETE_NAME_COLUMN: usize = 20;
fn autocomplete_popup_width(items: &[AutocompleteItem], available: u16) -> u16 {
let widest_name = items
.iter()
.map(|c| c.value.chars().count())
.max()
.unwrap_or(0)
.max(AUTOCOMPLETE_NAME_COLUMN);
let widest_desc = items
.iter()
.map(|c| c.description.chars().count())
.max()
.unwrap_or(0);
let content = 1 + widest_name + 1 + widest_desc + 1;
(content as u16 + 4).min(available)
}
fn autocomplete_popup_height(match_count: usize) -> u16 {
match_count.min(AUTOCOMPLETE_MAX_VISIBLE) as u16 + 2
}
fn scroll_offset(selected: usize, count: usize, visible: usize) -> usize {
if visible == 0 || count <= visible {
return 0;
}
let max_offset = count - visible;
selected.saturating_sub(visible - 1).min(max_offset)
}
fn autocomplete_scroll_offset(selected: usize, match_count: usize) -> usize {
scroll_offset(selected, match_count, AUTOCOMPLETE_MAX_VISIBLE)
}
fn render_approval(
frame: &mut Frame,
request: &crate::channels::ApprovalRequest,
area: Rect,
palette: &ColorPalette,
) {
let frame_area = Rect {
height: APPROVAL_FRAME_HEIGHT.min(area.height),
..area
};
let block = frame_block(palette.accent);
let inner = block.inner(frame_area);
frame.render_widget(block, frame_area);
let lines = vec![
Line::from(vec![
Span::styled(STEP_GLYPH, Style::default().fg(palette.accent)),
Span::styled(request.detail.clone(), Style::default().fg(palette.fg)),
]),
Line::from(Span::styled(
" y allow · a always for this · n deny",
Style::default().fg(palette.dim),
)),
];
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_input(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
if let Some(request) = &state.pending_approval {
render_approval(frame, request, area, palette);
return;
}
let show_autocomplete = state.autocomplete_active && !state.autocomplete_matches.is_empty();
let input_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: INPUT_FRAME_HEIGHT,
};
let block = frame_block(palette.accent);
let inner = block.inner(input_area);
frame.render_widget(block, input_area);
let line = Line::from(vec![
Span::styled(USER_GLYPH, Style::default().fg(palette.accent)),
Span::styled(state.input.as_str(), Style::default().fg(palette.fg)),
]);
frame.render_widget(Paragraph::new(line), inner);
let cursor_x = (inner.x + USER_GLYPH.chars().count() as u16 + state.input_cursor as u16)
.min(inner.x + inner.width.saturating_sub(1));
frame.set_cursor_position((cursor_x, inner.y));
if show_autocomplete {
let items = &state.autocomplete_matches;
let popup_area = Rect {
x: inner.x + USER_GLYPH.chars().count() as u16,
y: area.y + INPUT_FRAME_HEIGHT,
width: autocomplete_popup_width(items, area.width.saturating_sub(2)),
height: autocomplete_popup_height(items.len()),
};
let offset = autocomplete_scroll_offset(state.autocomplete_selected, items.len());
let lines: Vec<Line<'static>> = items
.iter()
.enumerate()
.skip(offset)
.take(AUTOCOMPLETE_MAX_VISIBLE)
.map(|(i, item)| {
let style = if i == state.autocomplete_selected {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.fg)
};
Line::from(vec![
Span::styled(
format!(" {:<width$} ", item.value, width = AUTOCOMPLETE_NAME_COLUMN),
style,
),
Span::styled(item.description.clone(), Style::default().fg(palette.dim)),
])
})
.collect();
let popup = Paragraph::new(lines).block(frame_block(palette.dim));
frame.render_widget(ratatui::widgets::Clear, popup_area);
frame.render_widget(popup, popup_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn palette() -> ColorPalette {
ColorPalette::from_theme(&Theme::Dark)
}
fn rendered(msg: &ChatMessage, width: usize) -> Vec<String> {
message_lines(msg, &palette(), width)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn the_popup_reserves_a_row_per_visible_suggestion() {
assert_eq!(autocomplete_popup_height(3), 5);
assert_eq!(
autocomplete_popup_height(10),
AUTOCOMPLETE_MAX_VISIBLE as u16 + 2
);
}
#[test]
fn the_popup_widens_to_fit_the_longest_description() {
let items: Vec<AutocompleteItem> = AppState::slash_commands()
.iter()
.map(|c| AutocompleteItem {
value: c.name.to_string(),
description: c.description.to_string(),
})
.collect();
let width = autocomplete_popup_width(&items, 200);
let longest = items
.iter()
.map(|c| c.description.chars().count())
.max()
.unwrap();
assert!(width as usize >= AUTOCOMPLETE_NAME_COLUMN + longest);
assert_eq!(autocomplete_popup_width(&items, 30), 30);
}
#[test]
fn the_popup_scrolls_to_keep_the_selection_visible() {
assert_eq!(autocomplete_scroll_offset(2, 3), 0);
assert_eq!(
autocomplete_scroll_offset(AUTOCOMPLETE_MAX_VISIBLE - 1, 10),
0
);
assert_eq!(autocomplete_scroll_offset(AUTOCOMPLETE_MAX_VISIBLE, 10), 1);
assert_eq!(
autocomplete_scroll_offset(9, 10),
10 - AUTOCOMPLETE_MAX_VISIBLE
);
}
#[test]
fn the_palette_keeps_showing_rows_as_the_selection_descends() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut state = AppState::new();
state.handle_key(
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
&tx,
);
assert!(state.palette_open, "ctrl+k should open the palette");
let total = state.palette_matches.len();
assert!(total > 5, "need a scrollable list, got {}", total);
for step in 0..total {
let screen = buffer_rows(80, 24, |frame, area| {
let _ = area;
render_palette(frame, &state, &palette())
})
.join("\n");
let selected = &state.palette_matches[state.palette_selected].value;
assert!(
screen.contains(selected.as_str()),
"selection {:?} off screen at step {}:\n{}",
selected,
step,
screen
);
let before = state.palette_selected;
state.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), &tx);
assert_ne!(
before, state.palette_selected,
"Down must move the selection, or this test proves nothing"
);
}
}
#[test]
fn a_windowed_list_scrolls_only_once_the_cursor_reaches_the_bottom() {
assert_eq!(scroll_offset(2, 3, 5), 0);
assert_eq!(scroll_offset(4, 10, 5), 0);
assert_eq!(scroll_offset(5, 10, 5), 1);
assert_eq!(scroll_offset(9, 10, 5), 5);
assert_eq!(scroll_offset(3, 10, 0), 0);
}
#[test]
fn wraps_at_the_given_width() {
let lines = wrap_text("aaa bbb ccc ddd", 7);
assert_eq!(lines, vec!["aaa bbb", "ccc ddd"]);
assert!(lines.iter().all(|l| l.chars().count() <= 7));
}
#[test]
fn short_text_stays_on_one_line() {
assert_eq!(wrap_text("hello", 40), vec!["hello"]);
}
#[test]
fn breaks_a_word_longer_than_the_line() {
let lines = wrap_text("aaaaaaaaaa", 4);
assert!(lines.iter().all(|l| l.chars().count() <= 4), "{:?}", lines);
assert_eq!(lines.concat(), "aaaaaaaaaa");
}
#[test]
fn wrapping_preserves_multibyte_content() {
let lines = wrap_text("ação corrigida direito", 10);
assert!(lines.iter().all(|l| l.chars().count() <= 10), "{:?}", lines);
assert_eq!(lines.join(" "), "ação corrigida direito");
}
#[test]
fn long_message_produces_multiple_lines_instead_of_truncating() {
let long = "palavra ".repeat(20).trim_end().to_string();
let lines = rendered(&ChatMessage::Agent(long), 20);
assert!(lines.len() > 1, "expected wrapping, got {:?}", lines);
assert!(lines.iter().all(|l| l.chars().count() <= 20), "{:?}", lines);
}
#[test]
fn the_user_turn_is_marked_and_continuations_line_up_under_the_text() {
let lines = rendered(&ChatMessage::User("um dois tres quatro".to_string()), 12);
assert!(lines[0].starts_with(USER_GLYPH), "got {:?}", lines);
assert!(lines[1].starts_with(" "), "got {:?}", lines);
}
#[test]
fn the_agent_speaks_without_a_marker() {
let lines = rendered(&ChatMessage::Agent("um\ndois".to_string()), 40);
assert_eq!(lines, vec!["um", "dois"]);
}
#[test]
fn embedded_newlines_start_new_lines() {
let lines = rendered(&ChatMessage::System("um\ndois\ntres".to_string()), 40);
assert_eq!(lines, vec!["⏺ um", " dois", " tres"]);
}
#[test]
fn only_the_user_speaks_with_the_user_glyph() {
assert_eq!(
rendered(&ChatMessage::User("oi".to_string()), 40),
vec!["› oi"]
);
assert_eq!(
rendered(&ChatMessage::System("MCP raven connected".to_string()), 40),
vec!["⏺ MCP raven connected"]
);
}
#[test]
fn following_resolves_to_the_last_screenful() {
let mut state = AppState::new();
assert!(state.is_following_chat());
assert_eq!(state.resolve_scroll(6), 6);
}
#[test]
fn scrolling_forward_to_the_bottom_resumes_following() {
let mut state = AppState::new();
state.resolve_scroll(6);
state.scroll_back(2);
assert!(!state.is_following_chat());
assert_eq!(state.resolve_scroll(6), 4);
state.scroll_forward(2);
assert_eq!(state.resolve_scroll(6), 6);
assert!(
state.is_following_chat(),
"reaching the bottom must re-enable auto-follow"
);
}
fn buffer_rows(width: u16, height: u16, draw: impl FnOnce(&mut Frame, Rect)) -> Vec<String> {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
draw(frame, area);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn visible_chat(state: &mut AppState, width: u16, height: u16) -> Vec<String> {
buffer_rows(width, height, |frame, area| {
render_chat(frame, state, area, &palette())
})
}
fn numbered_state(count: usize) -> AppState {
let mut state = AppState::new();
state.messages.clear();
for i in 0..count {
state
.messages
.push(ChatMessage::System(format!("msg{}", i)));
}
state
}
#[test]
fn at_rest_the_newest_messages_are_visible() {
let mut state = numbered_state(20);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(screen.contains("msg19"), "newest missing:\n{}", screen);
assert!(
!screen.contains("msg0\n"),
"oldest should be off-screen:\n{}",
screen
);
}
#[test]
fn scrolling_up_reveals_older_messages() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
for _ in 0..5 {
state.scroll_back(1);
}
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg14"),
"expected older content:\n{}",
screen
);
assert!(
!screen.contains("msg19"),
"newest should have scrolled off:\n{}",
screen
);
}
#[test]
fn scrolling_back_down_returns_to_the_newest() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state.scroll_back(5);
visible_chat(&mut state, 30, 6);
state.scroll_forward(5);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg19"),
"should be back at the bottom:\n{}",
screen
);
}
#[test]
fn a_new_message_pins_the_view_to_the_bottom() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state
.messages
.push(ChatMessage::Agent("recem chegada".to_string()));
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("recem chegada"),
"new message not shown:\n{}",
screen
);
}
#[test]
fn scrolling_up_then_receiving_a_message_keeps_the_reader_in_place() {
let body = |screen: Vec<String>| screen[..screen.len() - 1].to_vec();
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state.scroll_back(5);
let before = body(visible_chat(&mut state, 30, 6));
state.messages.push(ChatMessage::Agent("nova".to_string()));
let after = body(visible_chat(&mut state, 30, 6));
assert_eq!(
before, after,
"a message arriving must not yank a scrolled-back reader"
);
}
#[test]
fn scrolled_back_the_transcript_says_how_much_is_below() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state.scroll_back(5);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(screen.contains("↓ 5 more"), "got:\n{}", screen);
}
#[test]
fn at_rest_there_is_no_scroll_marker() {
let mut state = numbered_state(20);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(!screen.contains("more"), "got:\n{}", screen);
}
#[test]
fn scrolling_stops_at_the_oldest_message() {
let mut state = numbered_state(20);
for _ in 0..500 {
state.scroll_back(1);
visible_chat(&mut state, 30, 6);
}
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg0"),
"oldest should be reachable:\n{}",
screen
);
}
#[test]
fn runaway_scrolling_stops_at_the_first_line() {
let mut state = AppState::new();
state.resolve_scroll(7);
for _ in 0..500 {
state.scroll_back(1);
}
assert_eq!(
state.resolve_scroll(7),
0,
"must not scroll above the first line"
);
}
fn statusline(state: &AppState, width: u16) -> String {
buffer_rows(width, 1, |frame, area| {
render_statusline(frame, state, area, &palette())
})
.remove(0)
}
#[test]
fn the_statusline_carries_network_and_model() {
let state = AppState::new();
let row = statusline(&state, 90);
assert!(row.contains(&state.active_network), "got: {:?}", row);
assert!(row.contains(&state.active_model), "got: {:?}", row);
assert!(row.contains("ctrl+k"), "got: {:?}", row);
}
#[test]
fn the_statusline_shows_how_full_the_context_window_is() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Context {
used: 2_100,
window: 4_096,
});
assert!(statusline(&state, 120).contains("ctx 2.1k/4.1k"));
}
#[test]
fn the_context_gauge_is_absent_until_a_turn_has_run() {
assert!(!statusline(&AppState::new(), 120).contains("ctx "));
}
#[test]
fn the_statusline_carries_the_whole_working_context() {
let mut state = AppState::new();
state.project_name = "demo".to_string();
state.active_account = "alice".to_string();
state.active_contract = Some("counter".to_string());
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
let row = statusline(&state, 120);
for expected in [
"testnet", "alice", "demo", "counter", "anthropic", "guarded", "Building contract", ] {
assert!(
row.contains(expected),
"{:?} missing from {:?}",
expected,
row
);
}
}
#[test]
fn the_risk_mode_says_whether_this_session_can_spend() {
let mut state = AppState::new();
assert!(statusline(&state, 90).contains("guarded"), "off by default");
state.mainnet_allowed = true;
let row = statusline(&state, 90);
assert!(row.contains("armed"), "got: {:?}", row);
assert!(!row.contains("guarded"), "got: {:?}", row);
}
#[test]
fn an_unset_account_or_project_reads_as_absent() {
let row = statusline(&AppState::new(), 120);
assert!(row.contains("no account"), "got: {:?}", row);
assert!(row.contains("no project"), "got: {:?}", row);
assert!(!row.contains("None"), "got: {:?}", row);
}
#[test]
fn a_long_model_tag_is_abbreviated_rather_than_costing_another_field() {
let mut state = AppState::new();
state.active_provider = "ollama".to_string();
state.active_model = "qwen3:4b-instruct-2507-q4_K_M".to_string();
state.project_name = "demo".to_string();
let row = statusline(&state, 100);
assert!(row.contains("demo"), "the project must survive: {:?}", row);
assert!(row.contains("ollama qwen3"), "got: {:?}", row);
assert!(row.contains('…'), "the tag must say it was cut: {:?}", row);
}
#[test]
fn a_narrow_row_gives_up_context_before_it_gives_up_the_operation() {
let mut state = AppState::new();
state.project_name = "some-rather-long-project-name".to_string();
state.active_account = "alice".to_string();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_deploy".to_string(),
));
let row = statusline(&state, 46);
assert!(row.contains("testnet"), "got: {:?}", row);
assert!(row.contains("Deploying"), "got: {:?}", row);
assert!(
!row.contains("some-rather-long-project-name"),
"the project name is the first thing to go: {:?}",
row
);
}
#[test]
fn the_statusline_shows_the_current_activity_while_working() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
let row = statusline(&state, 90);
assert!(row.contains("Building contract"), "got: {:?}", row);
}
#[test]
fn the_statusline_never_overflows_its_single_row() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"a very long tool status that would otherwise overflow the whole status line"
.to_string(),
));
for width in [30u16, 45, 60, 90] {
let row = statusline(&state, width);
assert!(
row.chars().count() <= width as usize,
"overflowed at width {}: {:?}",
width,
row
);
}
}
#[test]
fn the_hint_never_touches_the_text_on_its_left() {
let mut state = AppState::new();
state.active_model = "qwen3:4b-instruct-2507-q4_K_M".to_string();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
for width in 60..120u16 {
let row = statusline(&state, width);
let at = ["esc interrupt", "? shortcuts"]
.iter()
.find_map(|marker| row.find(marker));
if let Some(at) = at {
assert!(
row[..at].ends_with(" "),
"hint glued to the activity at width {}: {:?}",
width,
row
);
}
}
}
#[test]
fn a_tool_call_is_drawn_above_the_reply_it_fed() {
let mut state = AppState::new();
state.messages.push(ChatMessage::User("liste".to_string()));
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: list_dir".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ToolFinished {
name: "list_dir".to_string(),
ok: true,
});
state.handle_agent_update(crate::channels::AgentUpdate::ResponseChunk(
"aqui estao".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ResponseEnd);
let rows = buffer_rows(70, 20, |frame, _| {
render(frame, &mut state, &Theme::Dark);
});
let row_of = |needle: &str| {
rows.iter()
.position(|r| r.contains(needle))
.unwrap_or_else(|| panic!("{:?} not on screen: {:#?}", needle, rows))
};
assert!(row_of("liste") < row_of("list_dir"));
assert!(
row_of("list_dir") < row_of("aqui estao"),
"the tool call was drawn below the answer it produced: {:#?}",
rows
);
}
#[test]
fn each_round_trip_keeps_its_own_place_in_the_transcript() {
let mut state = AppState::new();
state.messages.push(ChatMessage::User("faca".to_string()));
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: grep".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ResponseChunk(
"primeiro".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: write_file".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ResponseChunk(
"segundo".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ResponseEnd);
let rows = buffer_rows(70, 20, |frame, _| {
render(frame, &mut state, &Theme::Dark);
});
let row_of = |needle: &str| {
rows.iter()
.position(|r| r.contains(needle))
.unwrap_or_else(|| panic!("{:?} not on screen: {:#?}", needle, rows))
};
assert!(row_of("grep") < row_of("primeiro"), "{:#?}", rows);
assert!(row_of("primeiro") < row_of("write_file"), "{:#?}", rows);
assert!(row_of("write_file") < row_of("segundo"), "{:#?}", rows);
}
#[test]
fn the_trace_says_how_each_tool_ended() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: read_file".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_deploy".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::ToolFinished {
name: "read_file".to_string(),
ok: true,
});
state.handle_agent_update(crate::channels::AgentUpdate::ToolFinished {
name: "caatinga_deploy".to_string(),
ok: false,
});
let screen = buffer_rows(90, 24, |frame, _| {
render(frame, &mut state, &Theme::Dark);
})
.join("\n");
assert!(screen.contains("read_file — done"), "{}", screen);
assert!(screen.contains("caatinga_deploy — failed"), "{}", screen);
assert!(!screen.contains("deploying to network"), "{}", screen);
}
#[test]
fn a_finished_phase_drops_its_ellipsis() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Thinking...".to_string(),
));
let running = buffer_rows(90, 24, |frame, _| {
render(frame, &mut state, &Theme::Dark);
})
.join("\n");
assert!(running.contains("Thinking..."), "{}", running);
state.handle_agent_update(crate::channels::AgentUpdate::ResponseEnd);
let settled = buffer_rows(90, 24, |frame, _| {
render(frame, &mut state, &Theme::Dark);
})
.join("\n");
assert!(settled.contains("Thought"), "{}", settled);
assert!(!settled.contains("Thinking..."), "{}", settled);
}
#[test]
fn a_tool_parked_on_approval_does_not_claim_to_be_working() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: write_file".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::Approval(
crate::channels::ApprovalRequest {
tool: "write_file".to_string(),
detail: "write_file → src/lib.rs".to_string(),
scope: "write_file:src/lib.rs".to_string(),
},
));
let screen = buffer_rows(90, 24, |frame, _| {
render(frame, &mut state, &Theme::Dark);
})
.join("\n");
assert!(
screen.contains("write_file — waiting for you"),
"{}",
screen
);
assert!(!screen.contains("write_file — writing file"), "{}", screen);
}
#[test]
fn an_approval_replaces_the_prompt_with_the_question_and_its_keys() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Approval(
crate::channels::ApprovalRequest {
tool: "write_file".to_string(),
detail: "write_file → src/lib.rs".to_string(),
scope: "write_file:src/lib.rs".to_string(),
},
));
let rows = buffer_rows(90, 24, |frame, _| {
render(frame, &mut state, &Theme::Dark);
});
let screen = rows.join("\n");
assert!(screen.contains("src/lib.rs"), "{}", screen);
assert!(screen.contains("y allow"), "{}", screen);
assert!(screen.contains("a always"), "{}", screen);
assert!(screen.contains("n deny"), "{}", screen);
let prompt_rows = rows.iter().filter(|r| r.contains("› ")).count();
assert_eq!(prompt_rows, 0, "{}", screen);
}
#[test]
fn the_statusline_offers_the_stop_key_while_a_turn_runs() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Thinking...".to_string(),
));
assert!(statusline(&state, 90).contains("esc interrupt"));
state.handle_agent_update(crate::channels::AgentUpdate::ResponseEnd);
let idle = statusline(&state, 90);
assert!(!idle.contains("esc interrupt"), "got {:?}", idle);
assert!(idle.contains("? shortcuts"));
}
#[test]
fn the_welcome_banner_never_ends_mid_word() {
let mut state = AppState::new();
state.active_provider = "ollama".to_string();
state.active_model = "qwen3:4b-instruct-2507-q4_K_M".to_string();
state.project_name = "my-app".to_string();
for width in [50u16, 60, 70, 78, 100] {
let rows = buffer_rows(width, 16, |frame, _| {
render(frame, &mut state, &Theme::Dark);
});
for row in &rows {
assert!(
row.chars().count() <= width as usize,
"overflowed at {}: {:?}",
width,
row
);
}
let hint = rows
.iter()
.find(|r| r.contains("ctrl+k"))
.map(|r| r.trim_matches(|c: char| c == '│' || c == ' ').to_string())
.unwrap_or_else(|| panic!("no hint at {}", width));
assert!(
[
"Type / for commands · ctrl+k for the palette · ? for shortcuts",
"/ commands · ctrl+k palette · ? shortcuts",
"/ · ctrl+k · ?",
]
.contains(&hint.as_str()),
"hint was cut at {}: {:?}",
width,
hint
);
let model_row = rows
.iter()
.find(|r| r.contains("ollama"))
.unwrap_or_else(|| panic!("no model row at {}", width));
assert!(
model_row.contains(&state.active_model) || model_row.contains('…'),
"cut without an ellipsis at {}: {:?}",
width,
model_row
);
}
}
#[test]
fn a_mainnet_that_cannot_sign_is_marked_read_only() {
let mut state = AppState::new();
state.active_network = "mainnet".to_string();
state.mainnet_allowed = false;
assert!(statusline(&state, 90).contains("mainnet (read-only)"));
state.mainnet_allowed = true;
let signing = statusline(&state, 90);
assert!(signing.contains("mainnet"));
assert!(!signing.contains("read-only"), "got {:?}", signing);
}
#[test]
fn a_network_that_cannot_spend_carries_no_such_warning() {
let mut state = AppState::new();
state.active_network = "testnet".to_string();
assert!(!statusline(&state, 90).contains("read-only"));
}
#[test]
fn mainnet_is_called_out_in_the_statusline() {
let mut state = AppState::new();
state.active_network = "mainnet".to_string();
assert!(statusline(&state, 90).contains("mainnet"));
}
#[test]
fn a_contract_id_is_abbreviated_rather_than_eating_the_status_line() {
assert_eq!(abbreviate("CABCDEFGHIJKLMNOPQRSTUVWXYZ234567"), "CABC…4567");
assert_eq!(abbreviate("CABCDEF"), "CABCDEF");
}
#[test]
fn truncation_marks_what_it_cut() {
assert_eq!(truncate_to("abcdef", 4), "abc…");
assert_eq!(truncate_to("abc", 10), "abc");
}
fn whole_screen(state: &mut AppState, width: u16, height: u16) -> Vec<String> {
buffer_rows(width, height, |frame, _area| {
render(frame, state, &Theme::Dark)
})
}
#[test]
fn the_conversation_and_the_input_each_get_a_rounded_frame() {
let mut state = AppState::new();
let rows = whole_screen(&mut state, 60, 12);
let top_corners: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, r)| r.starts_with('╭'))
.map(|(i, _)| i)
.collect();
assert_eq!(
top_corners,
vec![0, 8],
"expected two frames, got:\n{}",
rows.join("\n")
);
assert!(rows[10].starts_with('╰'), "got:\n{}", rows.join("\n"));
assert!(
!rows[11].contains('│') && rows[11].contains("testnet"),
"status line must stay outside the frames:\n{}",
rows.join("\n")
);
}
#[test]
fn framed_content_keeps_a_column_of_air() {
let mut state = AppState::new();
state.messages.push(ChatMessage::Agent("olá".to_string()));
state.input = "oi".to_string();
for row in whole_screen(&mut state, 60, 12) {
if let Some(rest) = row.strip_prefix('│') {
assert!(
rest.starts_with(' '),
"content flush against the frame: {:?}",
row
);
}
}
}
fn welcome(state: &AppState, width: u16, height: u16) -> String {
buffer_rows(width, height, |frame, area| {
render_welcome(frame, state, area, &palette())
})
.join("\n")
}
#[test]
fn the_banner_introduces_the_session() {
let state = AppState::new();
let screen = welcome(&state, 90, 10);
assert!(screen.contains("procyon"), "got:\n{}", screen);
assert!(screen.contains("Stellar"), "got:\n{}", screen);
for row in STELLAR_MARK {
assert!(screen.contains(row.trim()), "mark missing:\n{}", screen);
}
assert!(screen.contains(&state.cwd_label), "got:\n{}", screen);
assert!(screen.contains(&state.active_network), "got:\n{}", screen);
assert!(screen.contains("ctrl+k"), "got:\n{}", screen);
}
#[test]
fn the_working_directory_is_written_the_way_a_person_writes_it() {
let state = AppState::new();
if std::env::var_os("HOME").is_some() && state.cwd_label != "." {
assert!(
!state.cwd_label.starts_with("/home/"),
"expected a ~-relative path, got {:?}",
state.cwd_label
);
}
assert_eq!(state.cwd_label, AppState::new().cwd_label, "must be stable");
}
#[test]
fn the_banner_gives_way_to_the_transcript() {
let mut state = AppState::new();
assert!(!has_conversation(&state));
state.messages.push(ChatMessage::User("oi".to_string()));
assert!(has_conversation(&state));
}
#[test]
fn a_command_reply_is_enough_to_retire_the_banner() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut state = AppState::new();
for c in "/help".chars() {
state.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), &tx);
}
state.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &tx);
assert!(has_conversation(&state), "banner would cover /help output");
let screen = buffer_rows(90, 20, |frame, area| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0)])
.split(area);
render_chat(frame, &mut state, chunks[0], &palette())
})
.join("\n");
assert!(screen.contains("Quick actions"), "got:\n{}", screen);
}
fn execution_rows(state: &AppState, width: usize) -> Vec<String> {
state
.execution_steps
.iter()
.flat_map(|step| execution_step_lines(step, &palette(), width))
.collect::<Vec<_>>()
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn a_phase_is_a_step_and_a_tool_call_hangs_off_it() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Thinking...".to_string(),
));
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"Using tool: caatinga_build".to_string(),
));
let rows = execution_rows(&state, 60);
assert!(
rows.iter().any(|r| r.starts_with("⏺ Thought")),
"got {:?}",
rows
);
assert!(
rows.iter()
.any(|r| r.starts_with(" ⎿ caatinga_build — building contract")),
"got {:?}",
rows
);
}
#[test]
fn execution_labels_survive_multibyte_content_at_any_width() {
let mut state = AppState::new();
state.handle_agent_update(crate::channels::AgentUpdate::Status(
"compilação atualizações — configuração".to_string(),
));
for width in 1..40usize {
let rows = execution_rows(&state, width);
assert!(!rows.is_empty(), "no rows at width {}", width);
}
}
}