use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame;
use super::app::{App, RenderedTurn};
use super::theme::Theme;
pub fn draw(f: &mut Frame, app: &App) {
let input_height = input_height(&app.input).clamp(1, 8);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), Constraint::Length(input_height + 2), ])
.split(f.area());
draw_header(f, chunks[0], app);
draw_conversation(f, chunks[1], app);
draw_status(f, chunks[2], app);
draw_input(f, chunks[3], app);
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let line = Line::from(vec![Span::styled(
format!(
"merlion · {} · session {} · {} skills · {} memories",
app.model, app.session_id_short, app.skill_count, app.memory_count
),
app.theme.header,
)]);
f.render_widget(Paragraph::new(line), area);
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let style = if app.status.starts_with("running tool") || app.status.starts_with("thinking") {
app.theme.status_busy
} else if app.status.contains("exhausted") || app.status.contains("error") {
app.theme.tool_err
} else {
app.theme.status_idle
};
let dim = Style::default().add_modifier(Modifier::DIM);
let mut spans = vec![Span::styled(format!("· {}", app.status), style)];
let usage = &app.usage;
if usage.prompt_tokens.is_some()
|| usage.completion_tokens.is_some()
|| usage.total_tokens.is_some()
{
let p = usage.prompt_tokens.unwrap_or(0);
let c = usage.completion_tokens.unwrap_or(0);
let t = usage.total_tokens.unwrap_or(p + c);
spans.push(Span::styled(
format!(" · tokens: {p} in / {c} out / {t} total"),
dim,
));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn draw_conversation(f: &mut Frame, area: Rect, app: &App) {
let lines = render_turns(&app.messages, &app.theme);
let total = lines.len() as u16;
let text = Text::from(lines);
let visible = area.height;
let max_scroll_top = total.saturating_sub(visible);
let scroll_top = max_scroll_top.saturating_sub(app.scroll);
let para = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((scroll_top, 0));
f.render_widget(para, area);
}
fn render_turns(turns: &[RenderedTurn], theme: &Theme) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
for turn in turns {
match turn {
RenderedTurn::UserText(text) => {
let mut iter = text.lines();
if let Some(first) = iter.next() {
out.push(Line::from(vec![
Span::styled("you › ", theme.user_text),
Span::raw(first.to_string()),
]));
}
for rest in iter {
out.push(Line::from(Span::raw(format!(" {rest}"))));
}
out.push(Line::from(""));
}
RenderedTurn::AssistantText(text) => {
if text.is_empty() {
continue;
}
let mut iter = text.lines();
if let Some(first) = iter.next() {
out.push(Line::from(vec![
Span::styled("merlion › ", theme.assistant_text),
Span::styled(first.to_string(), theme.assistant_text),
]));
}
for rest in iter {
out.push(Line::from(Span::styled(
format!(" {rest}"),
theme.assistant_text,
)));
}
out.push(Line::from(""));
}
RenderedTurn::ToolCall {
name,
args,
content,
is_error,
finished,
} => {
let args_preview = preview_args(args);
out.push(Line::from(Span::styled(
format!("· tool {name} {args_preview}"),
theme.tool_call,
)));
if *finished {
let head: String = content
.lines()
.next()
.unwrap_or("")
.chars()
.take(120)
.collect();
let tag = if *is_error { "ERR" } else { "ok" };
let style = if *is_error {
theme.tool_err
} else {
theme.tool_ok
};
out.push(Line::from(Span::styled(
format!(" ↪ {tag}: {head}"),
style,
)));
} else {
out.push(Line::from(Span::styled(" ↪ …", theme.tool_call)));
}
}
RenderedTurn::Info(text) => {
for l in text.lines() {
out.push(Line::from(Span::styled(l.to_string(), theme.info)));
}
}
}
}
out
}
fn preview_args(v: &serde_json::Value) -> String {
let s = v.to_string();
let max = 80;
if s.chars().count() <= max {
s
} else {
let truncated: String = s.chars().take(max).collect();
format!("{truncated}…")
}
}
fn input_height(input: &str) -> u16 {
let lines = input.split('\n').count() as u16;
lines.max(1)
}
fn draw_input(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(app.theme.input_prompt)
.title(Span::styled(" input ", app.theme.input_prompt));
let inner = block.inner(area);
f.render_widget(block, area);
let text = if app.input.is_empty() {
Text::from(Line::from(Span::styled(
"type a message — Enter sends, Ctrl+J for newline",
app.theme.info,
)))
} else {
let mut lines: Vec<Line> = Vec::new();
for l in app.input.split('\n') {
lines.push(Line::from(l.to_string()));
}
Text::from(lines)
};
let para = Paragraph::new(text).wrap(Wrap { trim: false });
f.render_widget(para, inner);
let (row, col) = cursor_rowcol(&app.input, app.cursor);
if inner.width > 0 && inner.height > 0 {
let x = inner.x + col.min(inner.width.saturating_sub(1) as usize) as u16;
let y = inner.y + row.min(inner.height.saturating_sub(1) as usize) as u16;
f.set_cursor_position((x, y));
}
}
fn cursor_rowcol(s: &str, byte_pos: usize) -> (usize, usize) {
let before = &s[..byte_pos.min(s.len())];
let mut row = 0usize;
let mut col = 0usize;
for c in before.chars() {
if c == '\n' {
row += 1;
col = 0;
} else {
col += 1;
}
}
(row, col)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_turn_uses_theme_user_text_style() {
let theme = Theme::load();
let turns = vec![RenderedTurn::UserText("hello".into())];
let lines = render_turns(&turns, &theme);
let first_span = &lines[0].spans[0];
assert_eq!(first_span.content, "you › ");
assert_eq!(first_span.style, theme.user_text);
}
#[test]
fn tool_call_error_uses_theme_tool_err() {
let theme = Theme::load();
let turns = vec![RenderedTurn::ToolCall {
name: "bash".into(),
args: serde_json::json!({"cmd": "ls"}),
content: "boom".into(),
is_error: true,
finished: true,
}];
let lines = render_turns(&turns, &theme);
assert_eq!(lines[0].spans[0].style, theme.tool_call);
assert_eq!(lines[1].spans[0].style, theme.tool_err);
}
#[test]
fn tool_call_ok_uses_theme_tool_ok() {
let theme = Theme::load();
let turns = vec![RenderedTurn::ToolCall {
name: "bash".into(),
args: serde_json::json!({"cmd": "ls"}),
content: "fine".into(),
is_error: false,
finished: true,
}];
let lines = render_turns(&turns, &theme);
assert_eq!(lines[1].spans[0].style, theme.tool_ok);
}
#[test]
fn info_line_uses_theme_info() {
let theme = Theme::load();
let turns = vec![RenderedTurn::Info("welcome".into())];
let lines = render_turns(&turns, &theme);
assert_eq!(lines[0].spans[0].style, theme.info);
}
}