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;
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.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()
}
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(
format!(" · {} {}", state.active_provider, state.active_model),
dim,
),
]),
Line::from(""),
Line::from(Span::styled(
" Type / for commands · ctrl+k for the palette · ? for shortcuts",
dim,
)),
];
frame.render_widget(Paragraph::new(lines), area);
}
fn render_statusline(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let dim = Style::default().fg(palette.dim);
let mut spans = vec![Span::raw(" ")];
let network_style = if state.active_network == "mainnet" {
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.network)
};
spans.push(Span::styled(state.active_network.clone(), network_style));
spans.push(Span::styled(
format!(" · {} {}", state.active_provider, state.active_model),
dim,
));
if let Some(contract) = &state.active_contract {
spans.push(Span::styled(format!(" · {}", abbreviate(contract)), 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...");
spans.push(Span::styled(" · ", dim));
spans.push(Span::styled(
format!("{} {}", spinner, activity),
Style::default().fg(palette.accent),
));
}
AppStatus::NeedsCredential => {
spans.push(Span::styled(" · ", dim));
spans.push(Span::styled(
"● needs login",
Style::default().fg(palette.err),
));
}
AppStatus::Ready => {}
}
let width = area.width as usize;
let hint = if width >= 60 {
"? shortcuts · ctrl+k "
} else {
"ctrl+k "
};
let mut used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if used > width {
let mut budget = width;
for span in spans.iter_mut() {
let len = span.content.chars().count();
if len <= budget {
budget -= len;
} else {
span.content = truncate_to(span.content.as_ref(), budget).into();
budget = 0;
}
}
used = width;
}
let gap = 2;
if 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 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),
),
ChatMessage::Event(text) => {
if text.starts_with("Using tool:") || text == "Thinking..." {
return Vec::new();
}
(
USER_GLYPH,
Style::default().fg(palette.dim),
Style::default().fg(palette.dim),
)
}
};
let content = match msg {
ChatMessage::User(text)
| ChatMessage::Agent(text)
| ChatMessage::System(text)
| ChatMessage::Event(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();
for msg in &state.messages {
lines.extend(message_lines(msg, palette, inner_width));
}
if !state.execution_steps.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.extend(execution_lines(state, 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_lines(state: &AppState, palette: &ColorPalette, width: usize) -> Vec<Line<'static>> {
let mut out = Vec::new();
for step in &state.execution_steps {
let is_substep = step.label.to_lowercase().starts_with("using tool:");
let (glyph, indent, style) = if is_substep {
(SUBSTEP_GLYPH, " ", Style::default().fg(palette.dim))
} else {
let color = match step.state {
crate::app::ExecutionStepState::Done => palette.ok,
crate::app::ExecutionStepState::Failed => palette.err,
crate::app::ExecutionStepState::Running => palette.accent,
};
(STEP_GLYPH, "", 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);
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) -> String {
let lower = raw.to_lowercase();
if lower.contains("thinking") {
return "Thinking...".to_string();
}
if lower.starts_with("using tool:") {
let tool = raw.split(':').nth(1).unwrap_or("").trim();
return format!("{} — {}", tool, friendly_tool_desc(tool));
}
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_input(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
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 event_messages_get_a_trace_glyph_not_a_speaker_label() {
let lines = rendered(&ChatMessage::Event("MCP raven connected".to_string()), 40);
assert_eq!(lines, vec!["› MCP raven connected"]);
}
#[test]
fn tool_event_messages_are_suppressed_inline_in_favor_of_execution_steps() {
let lines = rendered(&ChatMessage::Event("Using tool: build".to_string()), 40);
assert!(
lines.is_empty(),
"tool traces should be empty inline (they become execution steps), got {:?}",
lines
);
}
#[test]
fn multiline_event_messages_indent_continuations() {
let lines = rendered(&ChatMessage::Event("um\ndois".to_string()), 40);
assert_eq!(lines, vec!["› um", " dois"]);
}
#[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_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);
if let Some(at) = row.find("? shortcuts") {
assert!(
row[..at].ends_with(" "),
"hint glued to the activity at width {}: {:?}",
width,
row
);
}
}
}
#[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> {
execution_lines(state, &palette(), width)
.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("⏺ Thinking")),
"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);
}
}
}