use crate::cell::{Attributes, Cell, Color};
use crate::component::Component;
use crate::event::{Event, KeyCode, MouseEventKind};
use crate::surface::{Rect, Surface};
use crate::terminal::Size;
use crate::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRole {
User,
Assistant,
ToolResult,
}
#[derive(Debug, Clone)]
pub enum ContentBlockDisplay {
Text { content: String },
Thinking { content: String, collapsed: bool },
ToolCall {
id: String,
name: String,
arguments: String,
},
ToolResult {
tool_name: String,
content: String,
is_error: bool,
},
Error {
title: String,
message: String,
retryable: bool,
},
}
#[derive(Debug, Clone)]
pub struct ChatMessageDisplay {
pub role: MessageRole,
pub content_blocks: Vec<ContentBlockDisplay>,
pub timestamp: i64,
}
#[derive(Debug, Clone)]
pub struct StreamingState {
pub message: ChatMessageDisplay,
pub active_content_index: usize,
}
pub struct ChatView {
messages: Vec<ChatMessageDisplay>,
streaming: Option<StreamingState>,
theme: Theme,
dirty: bool,
scroll_offset: u16,
content_height: u16,
last_area_width: u16,
rendered_lines: Vec<RenderedMessage>,
#[allow(dead_code)]
focused_thinking: Option<(usize, usize)>,
}
#[derive(Debug, Clone)]
struct RenderedMessage {
#[allow(dead_code)]
role: MessageRole,
lines: Vec<RenderedLine>,
}
#[derive(Debug, Clone)]
struct RenderedLine {
cells: Vec<StyledCell>,
}
#[derive(Debug, Clone)]
struct StyledCell {
ch: char,
fg: Color,
bg: Color,
attrs: Attributes,
}
impl StyledCell {
fn new(ch: char, fg: Color, bg: Color, attrs: Attributes) -> Self {
Self { ch, fg, bg, attrs }
}
fn to_cell(&self) -> Cell {
Cell {
char: self.ch,
fg: self.fg,
bg: self.bg,
attrs: self.attrs,
}
}
}
const ASSISTANT_MARGIN: u16 = 2;
#[allow(dead_code)]
const TOOL_MARGIN: u16 = 4;
#[allow(dead_code)]
const THINKING_COLLAPSED_LINES: usize = 1;
const TOOL_RESULT_PREVIEW_LINES: usize = 3;
const TOOL_RESULT_MAX_CHARS: usize = 120;
impl ChatView {
pub fn new(theme: Theme) -> Self {
Self {
messages: Vec::new(),
streaming: None,
theme,
dirty: true,
scroll_offset: 0,
content_height: 0,
last_area_width: 0,
rendered_lines: Vec::new(),
focused_thinking: None,
}
}
pub fn add_message(&mut self, msg: ChatMessageDisplay) {
self.messages.push(msg);
self.streaming = None;
self.dirty = true;
}
pub fn start_streaming(&mut self) {
self.streaming = Some(StreamingState {
message: ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: Vec::new(),
timestamp: chrono_now_millis(),
},
active_content_index: 0,
});
self.dirty = true;
}
pub fn stream_text_delta(&mut self, delta: &str) {
if let Some(ref mut state) = self.streaming {
if let Some(ContentBlockDisplay::Text { ref mut content }) =
state.message.content_blocks.last_mut()
{
content.push_str(delta);
} else {
state
.message
.content_blocks
.push(ContentBlockDisplay::Text {
content: delta.to_string(),
});
}
self.dirty = true;
}
}
pub fn stream_thinking_start(&mut self) {
if let Some(ref mut state) = self.streaming {
state
.message
.content_blocks
.push(ContentBlockDisplay::Thinking {
content: String::new(),
collapsed: false,
});
state.active_content_index = state.message.content_blocks.len() - 1;
self.dirty = true;
}
}
pub fn stream_thinking_delta(&mut self, delta: &str) {
if let Some(ref mut state) = self.streaming {
if let Some(ContentBlockDisplay::Thinking {
ref mut content, ..
}) = state.message.content_blocks.last_mut()
{
content.push_str(delta);
self.dirty = true;
}
}
}
pub fn stream_thinking_end(&mut self) {
if let Some(ref mut state) = self.streaming {
if let Some(ContentBlockDisplay::Thinking {
content: _,
ref mut collapsed,
}) = state.message.content_blocks.last_mut()
{
*collapsed = true;
self.dirty = true;
}
}
}
pub fn stream_tool_call(&mut self, id: String, name: String, arguments: String) {
if let Some(ref mut state) = self.streaming {
state
.message
.content_blocks
.push(ContentBlockDisplay::ToolCall {
id,
name,
arguments,
});
self.dirty = true;
}
}
pub fn stream_tool_result(&mut self, tool_name: String, content: String, is_error: bool) {
if let Some(ref mut state) = self.streaming {
state
.message
.content_blocks
.push(ContentBlockDisplay::ToolResult {
tool_name,
content,
is_error,
});
self.dirty = true;
}
}
pub fn finish_streaming(&mut self) {
if let Some(state) = self.streaming.take() {
self.messages.push(state.message);
self.dirty = true;
}
}
pub fn finish_streaming_error(&mut self, error: &str) {
if let Some(ref mut state) = self.streaming {
state
.message
.content_blocks
.push(ContentBlockDisplay::Error {
title: "Error".into(),
message: error.into(),
retryable: false,
});
}
self.finish_streaming();
}
pub fn add_error(
&mut self,
title: impl Into<String>,
message: impl Into<String>,
retryable: bool,
) {
self.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Error {
title: title.into(),
message: message.into(),
retryable,
}],
timestamp: chrono_now_millis(),
});
}
pub fn stream_error(
&mut self,
title: impl Into<String>,
message: impl Into<String>,
retryable: bool,
) {
if let Some(ref mut state) = self.streaming {
state
.message
.content_blocks
.push(ContentBlockDisplay::Error {
title: title.into(),
message: message.into(),
retryable,
});
self.dirty = true;
}
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn is_streaming(&self) -> bool {
self.streaming.is_some()
}
pub fn scroll_offset(&self) -> u16 {
self.scroll_offset
}
pub fn scroll_to_bottom(&mut self) {
self.reflow_if_needed(u16::MAX);
let max_offset = self.content_height.saturating_sub(1);
if self.scroll_offset != max_offset {
self.scroll_offset = max_offset;
self.dirty = true;
}
}
pub fn scroll_up(&mut self, n: u16) {
let new_offset = self.scroll_offset.saturating_sub(n);
if new_offset != self.scroll_offset {
self.scroll_offset = new_offset;
self.dirty = true;
}
}
pub fn scroll_down(&mut self, n: u16) {
self.reflow_if_needed(u16::MAX);
let max_offset = self.content_height.saturating_sub(1);
let new_offset = (self.scroll_offset + n).min(max_offset);
if new_offset != self.scroll_offset {
self.scroll_offset = new_offset;
self.dirty = true;
}
}
pub fn toggle_thinking(&mut self, message_idx: usize, block_idx: usize) {
if let Some(msg) = self.messages.get_mut(message_idx) {
if let Some(ContentBlockDisplay::Thinking {
content: _,
ref mut collapsed,
}) = msg.content_blocks.get_mut(block_idx)
{
*collapsed = !*collapsed;
self.dirty = true;
}
}
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
self.dirty = true;
}
pub fn clear(&mut self) {
self.messages.clear();
self.streaming = None;
self.rendered_lines.clear();
self.content_height = 0;
self.scroll_offset = 0;
self.dirty = true;
}
fn reflow_if_needed(&mut self, width: u16) {
let width_changed = width != self.last_area_width;
if !self.dirty && !width_changed && !self.rendered_lines_needs_update() {
return;
}
if width_changed {
self.last_area_width = width;
}
if width_changed || self.dirty {
self.rendered_lines.clear();
let mut total_height: u16 = 0;
for (mi, msg) in self.messages.iter().enumerate() {
let rendered = self.render_message(msg, mi, width);
total_height = total_height.saturating_add(rendered.lines.len() as u16);
self.rendered_lines.push(rendered);
}
if let Some(ref state) = self.streaming {
let rendered = self.render_message(&state.message, self.messages.len(), width);
total_height = total_height.saturating_add(rendered.lines.len() as u16);
self.rendered_lines.push(rendered);
}
self.content_height = total_height.max(1);
self.dirty = false;
} else if self.streaming.is_some() {
if !self.rendered_lines.is_empty() && self.streaming.is_some() {
self.content_height = self.content_height.saturating_sub(
self.rendered_lines
.last()
.map(|r| r.lines.len() as u16)
.unwrap_or(0),
);
self.rendered_lines.pop();
}
if let Some(ref state) = self.streaming {
let rendered = self.render_message(&state.message, self.messages.len(), width);
self.content_height = self
.content_height
.saturating_add(rendered.lines.len() as u16);
self.rendered_lines.push(rendered);
}
self.content_height = self.content_height.max(1);
}
}
fn rendered_lines_needs_update(&self) -> bool {
self.streaming.is_some()
}
fn render_message(
&self,
msg: &ChatMessageDisplay,
message_index: usize,
width: u16,
) -> RenderedMessage {
let mut lines: Vec<RenderedLine> = Vec::new();
let max_content_width = (width as usize).saturating_sub(ASSISTANT_MARGIN as usize * 2);
if max_content_width == 0 {
return RenderedMessage {
role: msg.role,
lines: vec![],
};
}
match msg.role {
MessageRole::User => {
lines.push(self.make_label_line(
" You",
self.theme.colors.primary,
Color::Default,
self.theme.fonts.bold,
));
for block in &msg.content_blocks {
self.render_text_block(block, max_content_width, &mut lines, ASSISTANT_MARGIN);
}
lines.push(empty_line());
}
MessageRole::Assistant => {
lines.push(self.make_label_line(
" Assistant",
self.theme.colors.accent,
Color::Default,
self.theme.fonts.bold,
));
for (bi, block) in msg.content_blocks.iter().enumerate() {
self.render_content_block(
block,
message_index,
bi,
max_content_width,
&mut lines,
);
}
lines.push(empty_line());
}
MessageRole::ToolResult => {
for block in &msg.content_blocks {
if let ContentBlockDisplay::ToolResult {
tool_name,
content,
is_error,
} = block
{
self.render_tool_result_block(
tool_name,
content,
*is_error,
max_content_width,
&mut lines,
);
}
}
}
}
RenderedMessage {
role: msg.role,
lines,
}
}
fn make_label_line(&self, text: &str, fg: Color, bg: Color, attrs: Attributes) -> RenderedLine {
let mut line = RenderedLine { cells: Vec::new() };
for _ in 0..ASSISTANT_MARGIN {
line.cells.push(StyledCell::new(
' ',
Color::Default,
Color::Default,
Attributes::new(),
));
}
for ch in text.chars() {
line.cells.push(StyledCell::new(ch, fg, bg, attrs));
}
line
}
fn render_text_block(
&self,
block: &ContentBlockDisplay,
max_width: usize,
lines: &mut Vec<RenderedLine>,
margin: u16,
) {
let text = match block {
ContentBlockDisplay::Text { content } => content.as_str(),
_ => return,
};
for raw_line in text.lines() {
if raw_line.is_empty() {
lines.push(margin_line(margin));
continue;
}
wrap_text(
raw_line,
max_width,
self.theme.colors.foreground,
Color::Default,
Attributes::new(),
margin,
lines,
);
}
if text.is_empty() {
lines.push(margin_line(margin));
}
}
fn render_content_block(
&self,
block: &ContentBlockDisplay,
message_index: usize,
block_index: usize,
max_width: usize,
lines: &mut Vec<RenderedLine>,
) {
match block {
ContentBlockDisplay::Text { content: _ } => {
self.render_text_block(block, max_width, lines, ASSISTANT_MARGIN);
}
ContentBlockDisplay::Thinking { content, collapsed } => {
self.render_thinking_block(
content,
*collapsed,
message_index,
block_index,
max_width,
lines,
);
}
ContentBlockDisplay::ToolCall {
id: _,
name,
arguments,
} => {
self.render_tool_call_block(name, arguments, max_width, lines);
}
ContentBlockDisplay::ToolResult {
tool_name,
content,
is_error,
} => {
self.render_tool_result_block(tool_name, content, *is_error, max_width, lines);
}
ContentBlockDisplay::Error {
title,
message,
retryable,
} => {
self.render_error_block(title, message, *retryable, max_width, lines);
}
}
}
fn render_thinking_block(
&self,
content: &str,
collapsed: bool,
_message_index: usize,
_block_index: usize,
max_width: usize,
lines: &mut Vec<RenderedLine>,
) {
let thinking_fg = self.theme.colors.muted;
let thinking_bg = Color::Default;
let indicator = if collapsed { "▸" } else { "▾" };
let header = format!("{} Thinking…", indicator);
lines.push(self.make_styled_line(
&header,
thinking_fg,
thinking_bg,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
if !collapsed {
for raw_line in content.lines() {
if raw_line.is_empty() {
lines.push(margin_line(ASSISTANT_MARGIN + 2));
continue;
}
wrap_text(
raw_line,
max_width.saturating_sub(2),
thinking_fg,
thinking_bg,
Attributes::new().with_italic(),
ASSISTANT_MARGIN + 2,
lines,
);
}
if content.is_empty() {
lines.push(margin_line(ASSISTANT_MARGIN + 2));
}
} else {
let preview: String = content
.lines()
.next()
.map(|l| {
let max_chars = max_width.saturating_sub(4);
if l.len() > max_chars {
format!(" {}…", &l[..max_chars.saturating_sub(1)])
} else {
format!(" {}", l)
}
})
.unwrap_or_default();
lines.push(self.make_styled_line(
&preview,
thinking_fg,
thinking_bg,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
}
lines.push(margin_line(ASSISTANT_MARGIN));
}
fn render_tool_call_block(
&self,
name: &str,
arguments: &str,
max_width: usize,
lines: &mut Vec<RenderedLine>,
) {
let tool_fg = self.theme.colors.warning;
let border_fg = self.theme.colors.border;
let arg_fg = self.theme.colors.muted;
let header = format!("┌─ tool: {} ", name);
lines.push(self.make_styled_line(
&header,
tool_fg,
Color::Default,
self.theme.fonts.bold,
ASSISTANT_MARGIN,
));
let arg_text = arguments.trim();
if !arg_text.is_empty() {
for raw_line in arg_text.lines().take(8) {
let truncated = if raw_line.len() > max_width.saturating_sub(6) {
format!("│ {}", &raw_line[..max_width.saturating_sub(7)])
} else {
format!("│ {}", raw_line)
};
lines.push(self.make_styled_line(
&truncated,
arg_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
let total_lines = arg_text.lines().count();
if total_lines > 8 {
lines.push(self.make_styled_line(
&format!("│ … ({} more lines)", total_lines - 8),
arg_fg,
Color::Default,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
}
}
lines.push(self.make_styled_line(
"└─",
border_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
fn render_tool_result_block(
&self,
tool_name: &str,
content: &str,
is_error: bool,
_max_width: usize,
lines: &mut Vec<RenderedLine>,
) {
let result_fg = if is_error {
self.theme.colors.error
} else {
self.theme.colors.success
};
let content_fg = if is_error {
self.theme.colors.error
} else {
self.theme.colors.muted
};
let border_fg = self.theme.colors.border;
let header = format!("┌─ result: {} ", tool_name);
lines.push(self.make_styled_line(
&header,
result_fg,
Color::Default,
self.theme.fonts.bold,
ASSISTANT_MARGIN,
));
let content_lines: Vec<&str> = content.lines().take(TOOL_RESULT_PREVIEW_LINES).collect();
for raw_line in content_lines {
let truncated = if raw_line.len() > TOOL_RESULT_MAX_CHARS {
format!(
"│ {}…",
&raw_line[..TOOL_RESULT_MAX_CHARS.saturating_sub(1)]
)
} else {
format!("│ {}", raw_line)
};
lines.push(self.make_styled_line(
&truncated,
content_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
let total_lines = content.lines().count();
if total_lines > TOOL_RESULT_PREVIEW_LINES {
lines.push(self.make_styled_line(
&format!(
"│ … ({} more lines)",
total_lines - TOOL_RESULT_PREVIEW_LINES
),
content_fg,
Color::Default,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
}
lines.push(self.make_styled_line(
"└─",
border_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
fn render_error_block(
&self,
title: &str,
message: &str,
retryable: bool,
max_width: usize,
lines: &mut Vec<RenderedLine>,
) {
let error_fg = self.theme.colors.error;
let border_fg = self.theme.colors.error;
let muted_fg = self.theme.colors.muted;
let header = format!("┌─ ⚠ {}", title);
lines.push(self.make_styled_line(
&header,
error_fg,
Color::Default,
self.theme.fonts.bold,
ASSISTANT_MARGIN,
));
for raw_line in message.lines().take(6) {
let truncated = if raw_line.len() > max_width.saturating_sub(4) {
format!("│ {}…", &raw_line[..max_width.saturating_sub(5)])
} else {
format!("│ {}", raw_line)
};
lines.push(self.make_styled_line(
&truncated,
muted_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
let total_lines = message.lines().count();
if total_lines > 6 {
lines.push(self.make_styled_line(
&format!("│ … ({} more lines)", total_lines - 6),
muted_fg,
Color::Default,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
}
if retryable {
lines.push(self.make_styled_line(
"│ ↻ This error may be temporary – you can retry.",
muted_fg,
Color::Default,
Attributes::new().with_italic(),
ASSISTANT_MARGIN,
));
}
lines.push(self.make_styled_line(
"└─",
border_fg,
Color::Default,
Attributes::new(),
ASSISTANT_MARGIN,
));
}
fn make_styled_line(
&self,
text: &str,
fg: Color,
bg: Color,
attrs: Attributes,
margin: u16,
) -> RenderedLine {
let mut line = RenderedLine { cells: Vec::new() };
for _ in 0..margin {
line.cells.push(StyledCell::new(
' ',
Color::Default,
Color::Default,
Attributes::new(),
));
}
for ch in text.chars() {
line.cells.push(StyledCell::new(ch, fg, bg, attrs));
}
line
}
fn paint(&mut self, surface: &mut Surface, area: Rect) {
for row in area.y..area.bottom() {
for col in area.x..area.right() {
surface.set(row, col, Cell::default());
}
}
let mut all_lines: Vec<&RenderedLine> = Vec::new();
for rm in &self.rendered_lines {
for line in &rm.lines {
all_lines.push(line);
}
}
let visible_height = area.height as usize;
let scroll = self.scroll_offset as usize;
for (vi, rendered_line) in all_lines
.iter()
.skip(scroll)
.take(visible_height)
.enumerate()
{
let row = area.y + vi as u16;
for (ci, sc) in rendered_line.cells.iter().enumerate() {
let col = area.x + ci as u16;
if col < area.x + area.width {
surface.set(row, col, sc.to_cell());
}
}
}
}
}
fn wrap_text(
text: &str,
max_width: usize,
fg: Color,
bg: Color,
attrs: Attributes,
margin: u16,
lines: &mut Vec<RenderedLine>,
) {
if max_width == 0 {
lines.push(margin_line(margin));
return;
}
let mut current_line = margin_prefix(margin);
let mut current_len: usize = 0;
for word in text.split_whitespace() {
let word_len = word.chars().count();
if current_len == 0 {
if word_len <= max_width {
for ch in word.chars() {
current_line.push(StyledCell::new(ch, fg, bg, attrs));
}
current_len = word_len;
} else {
for ch in word.chars() {
if current_len >= max_width {
lines.push(RenderedLine {
cells: std::mem::take(&mut current_line),
});
current_line = margin_prefix(margin);
current_len = 0;
}
current_line.push(StyledCell::new(ch, fg, bg, attrs));
current_len += 1;
}
}
} else if current_len + 1 + word_len <= max_width {
current_line.push(StyledCell::new(' ', fg, bg, attrs));
for ch in word.chars() {
current_line.push(StyledCell::new(ch, fg, bg, attrs));
}
current_len += 1 + word_len;
} else {
lines.push(RenderedLine {
cells: std::mem::take(&mut current_line),
});
current_line = margin_prefix(margin);
current_len = 0;
if word_len <= max_width {
for ch in word.chars() {
current_line.push(StyledCell::new(ch, fg, bg, attrs));
}
current_len = word_len;
} else {
for ch in word.chars() {
if current_len >= max_width {
lines.push(RenderedLine {
cells: std::mem::take(&mut current_line),
});
current_line = margin_prefix(margin);
current_len = 0;
}
current_line.push(StyledCell::new(ch, fg, bg, attrs));
current_len += 1;
}
}
}
}
if current_len > 0 || text.is_empty() {
lines.push(RenderedLine {
cells: current_line,
});
}
}
fn margin_prefix(margin: u16) -> Vec<StyledCell> {
let mut cells = Vec::with_capacity(margin as usize);
for _ in 0..margin {
cells.push(StyledCell::new(
' ',
Color::Default,
Color::Default,
Attributes::new(),
));
}
cells
}
fn margin_line(margin: u16) -> RenderedLine {
RenderedLine {
cells: margin_prefix(margin),
}
}
fn empty_line() -> RenderedLine {
RenderedLine { cells: Vec::new() }
}
fn chrono_now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
impl Component for ChatView {
fn name(&self) -> &str {
"ChatView"
}
fn request_render(&mut self) {
self.dirty = true;
}
fn is_dirty(&self) -> bool {
self.dirty || self.streaming.is_some()
}
fn clear_dirty(&mut self) {
if self.streaming.is_none() {
self.dirty = false;
}
}
fn handle_event(&mut self, event: &Event) -> bool {
match event {
Event::Key(key) => match key.code {
KeyCode::Up => {
self.scroll_up(1);
true
}
KeyCode::Down => {
self.scroll_down(1);
true
}
KeyCode::PageUp => {
self.scroll_up(10);
true
}
KeyCode::PageDown => {
self.scroll_down(10);
true
}
KeyCode::Home => {
self.scroll_offset = 0;
self.dirty = true;
true
}
KeyCode::End => {
self.scroll_to_bottom();
true
}
_ => false,
},
Event::Mouse(mouse) => match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_up(3);
true
}
MouseEventKind::ScrollDown => {
self.scroll_down(3);
true
}
_ => false,
},
Event::Resize(_) => {
self.dirty = true;
true
}
_ => false,
}
}
fn render(&mut self, surface: &mut Surface, area: Rect) {
if !area.is_valid() || area.width < 4 || area.height < 1 {
return;
}
let was_at_bottom = self.scroll_offset >= self.content_height.saturating_sub(2);
self.reflow_if_needed(area.width);
let max_offset = self
.content_height
.saturating_sub(area.height)
.saturating_sub(1);
if was_at_bottom || self.content_height <= area.height {
self.scroll_offset = max_offset;
}
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
self.paint(surface, area);
}
fn min_size(&self) -> Size {
Size {
width: 20,
height: 5,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> Theme {
Theme::dark()
}
#[test]
fn chat_view_starts_empty() {
let cv = ChatView::new(test_theme());
assert_eq!(cv.message_count(), 0);
assert!(!cv.is_streaming());
}
#[test]
fn add_and_count_messages() {
let mut cv = ChatView::new(test_theme());
cv.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: "Hello".into(),
}],
timestamp: 0,
});
cv.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![ContentBlockDisplay::Text {
content: "Hi there!".into(),
}],
timestamp: 1,
});
assert_eq!(cv.message_count(), 2);
}
#[test]
fn streaming_lifecycle() {
let mut cv = ChatView::new(test_theme());
cv.start_streaming();
assert!(cv.is_streaming());
cv.stream_text_delta("Hello");
cv.stream_text_delta(" world");
cv.finish_streaming();
assert!(!cv.is_streaming());
assert_eq!(cv.message_count(), 1);
}
#[test]
fn streaming_thinking_block() {
let mut cv = ChatView::new(test_theme());
cv.start_streaming();
cv.stream_text_delta("Let me think...");
cv.stream_thinking_start();
cv.stream_thinking_delta("I should consider...");
cv.stream_thinking_delta(" multiple options.");
cv.stream_thinking_end();
cv.stream_text_delta(" Here's my answer.");
cv.finish_streaming();
assert_eq!(cv.message_count(), 1);
let msg = &cv.messages[0];
assert_eq!(msg.content_blocks.len(), 3);
if let ContentBlockDisplay::Thinking { collapsed, .. } = &msg.content_blocks[1] {
assert!(collapsed);
} else {
panic!("Expected Thinking block at index 1");
}
}
#[test]
fn streaming_tool_call_and_result() {
let mut cv = ChatView::new(test_theme());
cv.start_streaming();
cv.stream_tool_call(
"call_123".into(),
"read_file".into(),
r#"{"path": "/tmp/test.txt"}"#.into(),
);
cv.stream_tool_result("read_file".into(), "File contents here".into(), false);
cv.stream_text_delta("I read the file.");
cv.finish_streaming();
assert_eq!(cv.message_count(), 1);
let msg = &cv.messages[0];
assert_eq!(msg.content_blocks.len(), 3); }
#[test]
fn toggle_thinking_block() {
let mut cv = ChatView::new(test_theme());
cv.add_message(ChatMessageDisplay {
role: MessageRole::Assistant,
content_blocks: vec![
ContentBlockDisplay::Text {
content: "Before".into(),
},
ContentBlockDisplay::Thinking {
content: "Deep thoughts".into(),
collapsed: true,
},
ContentBlockDisplay::Text {
content: "After".into(),
},
],
timestamp: 0,
});
cv.toggle_thinking(0, 1);
if let ContentBlockDisplay::Thinking { collapsed, .. } = &cv.messages[0].content_blocks[1] {
assert!(!collapsed);
}
cv.toggle_thinking(0, 1);
if let ContentBlockDisplay::Thinking { collapsed, .. } = &cv.messages[0].content_blocks[1] {
assert!(collapsed);
}
}
#[test]
fn scroll_clamps_to_content() {
let mut cv = ChatView::new(test_theme());
cv.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: "Hello".into(),
}],
timestamp: 0,
});
cv.scroll_down(1000);
assert!(cv.scroll_offset() < 1000);
}
#[test]
fn clear_empties_everything() {
let mut cv = ChatView::new(test_theme());
cv.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: "Hi".into(),
}],
timestamp: 0,
});
cv.start_streaming();
cv.clear();
assert_eq!(cv.message_count(), 0);
assert!(!cv.is_streaming());
}
#[test]
fn render_produces_cells() {
let mut cv = ChatView::new(test_theme());
cv.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: "Hello world".into(),
}],
timestamp: 0,
});
let mut surface = Surface::new(40, 10);
cv.render(&mut surface, Rect::new(0, 0, 40, 10));
let has_content = (0..10).any(|row| {
(0..40).any(|col| {
surface
.get(row, col)
.map(|c| c.char != ' ')
.unwrap_or(false)
})
});
assert!(has_content, "Expected some non-space cells to be rendered");
}
#[test]
fn streaming_error_finishes_message() {
let mut cv = ChatView::new(test_theme());
cv.start_streaming();
cv.stream_text_delta("Partial...");
cv.finish_streaming_error("Connection lost");
assert!(!cv.is_streaming());
assert_eq!(cv.message_count(), 1);
let msg = &cv.messages[0];
let last_block = msg.content_blocks.last().unwrap();
if let ContentBlockDisplay::Error {
title,
message,
retryable: _,
} = last_block
{
assert_eq!(title, "Error");
assert_eq!(message, "Connection lost");
} else {
panic!("Expected Error block as last block, got {:?}", last_block);
}
}
#[test]
fn word_wrap_long_line() {
let mut lines: Vec<RenderedLine> = Vec::new();
wrap_text(
"This is a reasonably long line that should wrap at the width boundary",
20,
Color::Default,
Color::Default,
Attributes::new(),
0,
&mut lines,
);
assert!(lines.len() > 1, "Long line should wrap into multiple lines");
for line in &lines {
assert!(
line.cells.len() <= 22, "Wrapped line should respect max width"
);
}
}
#[test]
fn render_thinking_block_collapsed_and_expanded() {
let cv = ChatView::new(test_theme());
let mut lines_collapsed: Vec<RenderedLine> = Vec::new();
cv.render_thinking_block(
"Some deep thinking content here",
true, 0,
0,
80,
&mut lines_collapsed,
);
assert_eq!(lines_collapsed.len(), 3);
let mut lines_expanded: Vec<RenderedLine> = Vec::new();
cv.render_thinking_block(
"Some deep thinking content here",
false, 0,
0,
80,
&mut lines_expanded,
);
assert!(lines_expanded.len() >= 3);
}
#[test]
fn add_error_message() {
let mut cv = ChatView::new(test_theme());
cv.add_error("Rate Limited", "Too many requests. Please wait.", true);
assert_eq!(cv.message_count(), 1);
let msg = &cv.messages[0];
assert_eq!(msg.content_blocks.len(), 1);
if let ContentBlockDisplay::Error {
title,
message,
retryable,
} = &msg.content_blocks[0]
{
assert_eq!(title, "Rate Limited");
assert_eq!(message, "Too many requests. Please wait.");
assert!(retryable);
} else {
panic!("Expected Error block");
}
}
#[test]
fn render_error_block_with_retry_hint() {
let cv = ChatView::new(test_theme());
let mut lines: Vec<RenderedLine> = Vec::new();
cv.render_error_block(
"Connection Error",
"Failed to connect to provider",
true, 80,
&mut lines,
);
assert!(lines.len() >= 3);
let has_retry_hint = lines.iter().any(|l| l.cells.iter().any(|c| c.ch == '↻'));
assert!(
has_retry_hint,
"Expected retry hint in rendered error block"
);
}
#[test]
fn render_error_block_without_retry() {
let cv = ChatView::new(test_theme());
let mut lines: Vec<RenderedLine> = Vec::new();
cv.render_error_block(
"Fatal",
"Something went wrong",
false, 80,
&mut lines,
);
assert!(lines.len() >= 2);
let has_retry_hint = lines.iter().any(|l| l.cells.iter().any(|c| c.ch == '↻'));
assert!(
!has_retry_hint,
"Should not have retry hint for non-retryable error"
);
}
#[test]
fn stream_error_into_streaming_message() {
let mut cv = ChatView::new(test_theme());
cv.start_streaming();
cv.stream_text_delta("Thinking...");
cv.stream_error("Timeout", "Request timed out", true);
assert!(cv.is_streaming());
cv.finish_streaming();
assert_eq!(cv.message_count(), 1);
assert_eq!(cv.messages[0].content_blocks.len(), 2);
}
}