use rataflow::{NodeContent, NodeRenderContext};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget};
use crate::state::session::AgentStatus;
pub const MAIN_NODE_DIMS: (f64, f64) = (30.0, 7.0);
pub const SUB_NODE_DIMS: (f64, f64) = (26.0, 6.0);
pub const CELL_MIN_WIDTH: u16 = 10;
pub const CELL_MIN_HEIGHT: u16 = 3;
#[derive(Debug, Clone)]
pub struct AgentNode {
pub title: String,
pub description: Option<String>,
pub status: AgentStatus,
pub tool_count: usize,
pub last_tool: Option<String>,
pub output_tokens: u64,
pub interactive: bool,
}
use crate::ui::truncate;
fn fmt_tokens(n: u64) -> String {
if n < 1_000 {
n.to_string()
} else if n < 1_000_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
format!("{:.1}M", n as f64 / 1_000_000.0)
}
}
impl NodeContent for AgentNode {
fn render(&self, ctx: &NodeRenderContext, buf: &mut Buffer) {
let palette = ctx.theme.palette();
let area = ctx.area;
if area.width == 0 || area.height == 0 {
return;
}
let glyph_color = crate::ui::status_color(self.status, &palette);
let status_text = crate::state::session::status_word(self.status, self.interactive);
if area.width < CELL_MIN_WIDTH || area.height < CELL_MIN_HEIGHT {
let mut fill = Style::default().bg(glyph_color);
if ctx.selected {
fill = fill.add_modifier(Modifier::REVERSED);
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
buf[(x, y)].set_char(' ').set_style(fill);
}
}
return;
}
let border_color = if ctx.selected {
palette.accent
} else {
palette.muted
};
let bg_style = Style::default().bg(palette.surface);
let border_style = bg_style.fg(border_color);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(bg_style);
if area.width >= 4 {
block = block.padding(Padding::horizontal(1));
}
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let glyph = if self.status == AgentStatus::Running && (ctx.animation_phase / 4) % 2 == 1 {
'○'
} else {
self.status.glyph()
};
let inner_w = inner.width as usize;
let title_budget = inner_w.saturating_sub(2); let title = truncate(&self.title, title_budget);
let title_line = Line::from(vec![
Span::styled(format!("{glyph} "), bg_style.fg(glyph_color)),
Span::styled(
title,
bg_style.fg(palette.text).add_modifier(Modifier::BOLD),
),
]);
let mut lines: Vec<Line> = Vec::new();
lines.push(title_line);
if let Some(desc) = self.description.as_ref().filter(|d| !d.is_empty()) {
let desc = truncate(desc, inner_w);
lines.push(Line::from(Span::styled(desc, bg_style.fg(palette.subtle))));
}
let tools_text = if let Some(last) = self.last_tool.as_ref() {
let prefix = format!("⚒ {} · ", self.tool_count);
let budget =
inner_w.saturating_sub(unicode_width::UnicodeWidthStr::width(prefix.as_str()));
format!("{prefix}{}", truncate(last, budget))
} else {
format!("⚒ {} tools", self.tool_count)
};
lines.push(Line::from(Span::styled(
tools_text,
bg_style.fg(palette.accent),
)));
let tokens = fmt_tokens(self.output_tokens);
let footer = if self.output_tokens > 0 {
Line::from(vec![
Span::styled(status_text, bg_style.fg(glyph_color)),
Span::styled(format!(" {tokens} tok"), bg_style.fg(palette.muted)),
])
} else {
Line::from(Span::styled(status_text, bg_style.fg(glyph_color)))
};
lines.push(footer);
for (i, line) in lines.into_iter().enumerate() {
if i as u16 >= inner.height {
break;
}
let rect = Rect::new(inner.x, inner.y + i as u16, inner.width, 1);
Paragraph::new(line).style(bg_style).render(rect, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_respects_budget() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello", 5), "hello");
assert_eq!(truncate("hello", 4), "hel…");
assert_eq!(truncate("hello", 1), "…");
assert_eq!(truncate("hello", 0), "");
}
#[test]
fn truncate_handles_multibyte() {
let s = "café crème brûlée";
let out = truncate(s, 5);
assert_eq!(out.chars().count(), 5);
}
#[test]
fn truncate_measures_display_columns_not_chars() {
use unicode_width::UnicodeWidthStr;
let s = "修复解析错误";
let out = truncate(s, 10);
assert!(out.width() <= 10, "{out:?} is {} columns", out.width());
assert!(out.ends_with('…'));
assert_eq!(truncate(s, 12), s);
}
#[test]
fn token_formatting() {
assert_eq!(fmt_tokens(0), "0");
assert_eq!(fmt_tokens(999), "999");
assert_eq!(fmt_tokens(1_500), "1.5k");
assert_eq!(fmt_tokens(2_000_000), "2.0M");
}
#[test]
fn glyphs_per_status() {
assert_eq!(AgentStatus::Running.glyph(), '●');
assert_eq!(AgentStatus::Done.glyph(), '✓');
assert_eq!(AgentStatus::Failed.glyph(), '✗');
}
fn render_into(area: ratatui::layout::Rect) -> Buffer {
use rataflow::Theme;
use rataflow::types::Position;
let node = AgentNode {
title: "claude".into(),
description: None,
status: AgentStatus::Done,
tool_count: 3,
last_tool: Some("Bash".into()),
output_tokens: 1200,
interactive: false,
};
let ctx = NodeRenderContext {
id: "main",
area,
selected: false,
dragging: false,
position_absolute: Position::new(0.0, 0.0),
theme: Theme::default(),
animation_phase: 0,
};
let mut buf = Buffer::empty(area);
node.render(&ctx, &mut buf);
buf
}
#[test]
fn card_level_renders_text() {
let area = ratatui::layout::Rect::new(0, 0, 24, 6);
let buf = render_into(area);
let text: String = (0..area.width).map(|x| buf[(x, 1)].symbol()).collect();
assert!(
text.contains("claude"),
"card level must show title: {text}"
);
}
#[test]
fn cell_level_renders_status_fill() {
use rataflow::Theme;
use ratatui::style::Color;
let area = ratatui::layout::Rect::new(0, 0, 6, 2);
let buf = render_into(area);
let palette = Theme::default().palette();
for y in 0..area.height {
for x in 0..area.width {
let cell = &buf[(x, y)];
assert_eq!(cell.symbol(), " ", "cell level draws no text/border");
assert_eq!(cell.style().bg, Some(palette.accent)); assert_ne!(cell.style().bg, Some(Color::Reset));
}
}
}
}