use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::{Block, Borders, Widget},
};
use crate::tui::tokens::compat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatNodeKind {
User,
Assistant,
ToolCall,
System,
Error,
}
impl ChatNodeKind {
pub fn icon(&self) -> &'static str {
match self {
ChatNodeKind::User => "👤",
ChatNodeKind::Assistant => "🤖",
ChatNodeKind::ToolCall => "🔌",
ChatNodeKind::System => "⚙️",
ChatNodeKind::Error => "❌",
}
}
pub fn all() -> &'static [ChatNodeKind] {
&[
ChatNodeKind::User,
ChatNodeKind::Assistant,
ChatNodeKind::ToolCall,
ChatNodeKind::System,
ChatNodeKind::Error,
]
}
pub fn color(&self) -> Color {
match self {
ChatNodeKind::User => compat::CYAN_500,
ChatNodeKind::Assistant => compat::GREEN_500,
ChatNodeKind::ToolCall => compat::PINK_500,
ChatNodeKind::System => compat::AMBER_500,
ChatNodeKind::Error => compat::RED_500,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChatNodeState {
#[default]
Idle,
Running,
Complete,
Failed,
}
impl ChatNodeState {
pub fn is_running(&self) -> bool {
matches!(self, ChatNodeState::Running)
}
pub fn border_color(&self) -> Color {
match self {
ChatNodeState::Idle => compat::SLATE_500,
ChatNodeState::Running => compat::AMBER_500,
ChatNodeState::Complete => compat::GREEN_500,
ChatNodeState::Failed => compat::RED_500,
}
}
}
#[derive(Debug, Clone)]
pub struct ChatNodeBox {
id: String,
kind: ChatNodeKind,
label: String,
index: u32,
state: ChatNodeState,
selected: bool,
animation_tick: u8,
}
impl ChatNodeBox {
pub fn new(id: &str, kind: ChatNodeKind) -> Self {
Self {
id: id.to_string(),
kind,
label: String::new(),
index: 0,
state: ChatNodeState::default(),
selected: false,
animation_tick: 0,
}
}
pub fn with_label(mut self, label: &str) -> Self {
self.label = label.to_string();
self
}
pub fn with_index(mut self, index: u32) -> Self {
self.index = index;
self
}
pub fn with_state(mut self, state: ChatNodeState) -> Self {
self.state = state;
self
}
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn id(&self) -> &str {
&self.id
}
pub fn kind(&self) -> ChatNodeKind {
self.kind
}
pub fn label(&self) -> &str {
&self.label
}
pub fn index(&self) -> u32 {
self.index
}
pub fn state(&self) -> ChatNodeState {
self.state
}
pub fn selected(&self) -> bool {
self.selected
}
pub fn tick(&mut self) {
if self.state.is_running() {
self.animation_tick = self.animation_tick.wrapping_add(1);
}
}
pub fn pulse_intensity(&self) -> f32 {
if !self.state.is_running() {
return 0.0;
}
let t = self.animation_tick as f32 / 30.0;
(t.sin() + 1.0) / 2.0
}
}
impl Widget for ChatNodeBox {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 3 || area.width < 10 {
return;
}
let width = area.width.min(30);
let _height = 3.min(area.height);
let border_color = if self.selected {
self.kind.color()
} else {
self.state.border_color()
};
let border_style = Style::default().fg(border_color);
let border_style = if self.selected || self.state.is_running() {
border_style.add_modifier(Modifier::BOLD)
} else {
border_style
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
block.render(area, buf);
let content = format!(
"@{} {} {}",
self.index,
self.kind.icon(),
truncate_label(&self.label, (width.saturating_sub(8)) as usize)
);
let content_style = Style::default().fg(self.kind.color());
buf.set_string(inner.x, inner.y, &content, content_style);
}
}
fn truncate_label(label: &str, max_len: usize) -> String {
if label.chars().count() <= max_len {
label.to_string()
} else if max_len <= 1 {
"…".to_string()
} else {
let truncated: String = label.chars().take(max_len.saturating_sub(1)).collect();
format!("{}…", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chat_node_kind_icons() {
assert_eq!(ChatNodeKind::User.icon(), "👤");
assert_eq!(ChatNodeKind::Assistant.icon(), "🤖");
assert_eq!(ChatNodeKind::ToolCall.icon(), "🔌");
assert_eq!(ChatNodeKind::System.icon(), "⚙️");
assert_eq!(ChatNodeKind::Error.icon(), "❌");
}
#[test]
fn test_chat_node_kind_all_variants() {
let kinds = ChatNodeKind::all();
assert_eq!(kinds.len(), 5);
}
#[test]
fn test_chat_node_kind_colors() {
assert_eq!(ChatNodeKind::User.color(), compat::CYAN_500);
assert_eq!(ChatNodeKind::Assistant.color(), compat::GREEN_500);
assert_eq!(ChatNodeKind::ToolCall.color(), compat::PINK_500);
assert_eq!(ChatNodeKind::System.color(), compat::AMBER_500);
assert_eq!(ChatNodeKind::Error.color(), compat::RED_500);
}
#[test]
fn test_chat_node_state_is_running() {
assert!(!ChatNodeState::Idle.is_running());
assert!(ChatNodeState::Running.is_running());
assert!(!ChatNodeState::Complete.is_running());
assert!(!ChatNodeState::Failed.is_running());
}
#[test]
fn test_chat_node_state_border_color() {
assert_eq!(ChatNodeState::Idle.border_color(), compat::SLATE_500);
assert_eq!(ChatNodeState::Running.border_color(), compat::AMBER_500);
assert_eq!(ChatNodeState::Complete.border_color(), compat::GREEN_500);
assert_eq!(ChatNodeState::Failed.border_color(), compat::RED_500);
}
#[test]
fn test_chat_node_state_default() {
let state = ChatNodeState::default();
assert_eq!(state, ChatNodeState::Idle);
}
#[test]
fn test_chat_node_box_creation() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User)
.with_label("Hello world")
.with_index(1);
assert_eq!(node.id(), "msg-001");
assert_eq!(node.kind(), ChatNodeKind::User);
assert_eq!(node.label(), "Hello world");
assert_eq!(node.index(), 1);
}
#[test]
fn test_chat_node_box_default_state() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User);
assert_eq!(node.state(), ChatNodeState::Idle);
assert!(!node.selected());
}
#[test]
fn test_chat_node_box_builder_pattern() {
let node = ChatNodeBox::new("msg-002", ChatNodeKind::Assistant)
.with_label("I'll help you...")
.with_index(2)
.with_state(ChatNodeState::Running)
.with_selected(true);
assert_eq!(node.id(), "msg-002");
assert_eq!(node.kind(), ChatNodeKind::Assistant);
assert_eq!(node.label(), "I'll help you...");
assert_eq!(node.index(), 2);
assert_eq!(node.state(), ChatNodeState::Running);
assert!(node.selected());
}
#[test]
fn test_chat_node_box_tick() {
let mut node =
ChatNodeBox::new("msg-001", ChatNodeKind::User).with_state(ChatNodeState::Running);
let initial = node.animation_tick;
node.tick();
assert_eq!(node.animation_tick, initial.wrapping_add(1));
}
#[test]
fn test_chat_node_box_tick_only_when_running() {
let mut node =
ChatNodeBox::new("msg-001", ChatNodeKind::User).with_state(ChatNodeState::Idle);
let initial = node.animation_tick;
node.tick();
assert_eq!(node.animation_tick, initial);
}
#[test]
fn test_chat_node_box_pulse_intensity() {
let node =
ChatNodeBox::new("msg-001", ChatNodeKind::User).with_state(ChatNodeState::Running);
let intensity = node.pulse_intensity();
assert!((0.0..=1.0).contains(&intensity));
}
#[test]
fn test_no_pulse_when_idle() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User).with_state(ChatNodeState::Idle);
assert_eq!(node.pulse_intensity(), 0.0);
}
#[test]
fn test_truncate_label_short() {
let result = truncate_label("Hello", 10);
assert_eq!(result, "Hello");
}
#[test]
fn test_truncate_label_long() {
let result = truncate_label("Hello, World!", 8);
assert_eq!(result, "Hello, …");
}
#[test]
fn test_truncate_label_empty() {
let result = truncate_label("", 10);
assert_eq!(result, "");
}
#[test]
fn test_truncate_label_exact_fit() {
let result = truncate_label("Hello", 5);
assert_eq!(result, "Hello");
}
#[test]
fn test_truncate_label_unicode() {
let result = truncate_label("你好世界", 3);
assert_eq!(result, "你好…");
}
#[test]
fn test_chat_node_box_render_basic() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User)
.with_label("Hello")
.with_index(1);
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
node.render(buf.area, &mut buf);
let content = buffer_to_string(&buf);
assert!(content.contains("@1"));
}
#[test]
fn test_chat_node_box_render_selected() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User)
.with_label("Hello")
.with_index(1)
.with_selected(true);
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 3));
node.render(buf.area, &mut buf);
let content = buffer_to_string(&buf);
assert!(!content.is_empty());
}
#[test]
fn test_chat_node_box_render_running() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::ToolCall)
.with_label("search...")
.with_index(3)
.with_state(ChatNodeState::Running);
let mut buf = Buffer::empty(Rect::new(0, 0, 25, 3));
node.render(buf.area, &mut buf);
let content = buffer_to_string(&buf);
assert!(content.contains("@3"));
}
#[test]
fn test_chat_node_box_render_too_small() {
let node = ChatNodeBox::new("msg-001", ChatNodeKind::User)
.with_label("Hello")
.with_index(1);
let mut buf = Buffer::empty(Rect::new(0, 0, 5, 2));
node.render(Rect::new(0, 0, 5, 2), &mut buf);
let cell = buf.cell((0, 0)).unwrap();
assert_eq!(cell.symbol(), " ");
}
#[test]
fn test_chat_node_box_exported() {
let _ = ChatNodeBox::new("test", ChatNodeKind::User);
}
fn buffer_to_string(buf: &Buffer) -> String {
buf.content.iter().map(|c| c.symbol()).collect()
}
}