use std::cell::{Cell, RefCell};
use crate::syntax::SyntaxHighlighter;
use tracing::debug;
use ratatui::{
buffer::Buffer,
layout::Rect,
prelude::Widget,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Paragraph, Wrap},
};
const RENDER_WINDOW_SIZE: usize = 50;
fn char_offset_to_byte(text: &str, char_offset: usize) -> usize {
text.char_indices()
.nth(char_offset)
.map(|(i, _)| i)
.unwrap_or(text.len())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineType {
Normal,
Header1,
Header2,
Header3,
ListItem,
CodeBlock,
}
impl LineType {
fn style(&self) -> Style {
match self {
LineType::Header1 => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
LineType::Header2 => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
LineType::Header3 => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
LineType::ListItem => Style::default().fg(Color::White),
LineType::CodeBlock => Style::default().fg(Color::Gray),
LineType::Normal => Style::default(),
}
}
}
fn parse_inline_markdown(text: &str, base_style: Style) -> Vec<Span<'_>> {
let mut spans = Vec::new();
let mut chars = text.chars().peekable();
let mut current = String::new();
let mut in_bold = false;
let mut in_italic = false;
let mut in_code = false;
while let Some(c) = chars.next() {
if c == '`' && !in_bold && !in_italic {
if in_code {
let style = Style::default().fg(Color::Yellow);
spans.push(Span::styled(current.clone(), style));
current.clear();
in_code = false;
} else {
if !current.is_empty() {
spans.push(Span::styled(current.clone(), base_style));
current.clear();
}
in_code = true;
}
continue;
}
if c == '*' && chars.peek() == Some(&'*') && !in_code {
chars.next(); if in_bold {
let style = base_style.add_modifier(Modifier::BOLD);
spans.push(Span::styled(current.clone(), style));
current.clear();
in_bold = false;
} else {
if !current.is_empty() {
spans.push(Span::styled(current.clone(), base_style));
current.clear();
}
in_bold = true;
}
continue;
}
if c == '*' && !in_code && !in_bold {
if in_italic {
let style = base_style.add_modifier(Modifier::ITALIC);
spans.push(Span::styled(current.clone(), style));
current.clear();
in_italic = false;
} else {
if !current.is_empty() {
spans.push(Span::styled(current.clone(), base_style));
current.clear();
}
in_italic = true;
}
continue;
}
current.push(c);
}
if !current.is_empty() {
let style = if in_code {
Style::default().fg(Color::Yellow)
} else if in_bold {
base_style.add_modifier(Modifier::BOLD)
} else if in_italic {
base_style.add_modifier(Modifier::ITALIC)
} else {
base_style
};
spans.push(Span::styled(current, style));
}
if spans.is_empty() {
spans.push(Span::styled(text, base_style));
}
spans
}
fn detect_line_type(line: &str) -> (LineType, &str) {
let trimmed = line.trim_start();
if trimmed.starts_with("### ") {
(
LineType::Header3,
trimmed.strip_prefix("### ").unwrap_or(trimmed),
)
} else if trimmed.starts_with("## ") {
(
LineType::Header2,
trimmed.strip_prefix("## ").unwrap_or(trimmed),
)
} else if trimmed.starts_with("# ") {
(
LineType::Header1,
trimmed.strip_prefix("# ").unwrap_or(trimmed),
)
} else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
(LineType::ListItem, line)
} else {
(LineType::Normal, line)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
System,
}
impl Role {
pub fn display_name(&self) -> &str {
match self {
Role::User => "USER",
Role::Assistant => "ASSISTANT",
Role::System => "SYSTEM",
}
}
pub fn badge_color(&self) -> Color {
match self {
Role::User => Color::Blue,
Role::Assistant => Color::Green,
Role::System => Color::Yellow,
}
}
}
#[derive(Debug, Clone)]
pub struct Message {
pub role: Role,
pub content: String,
pub timestamp: String,
}
impl Message {
pub fn new(role: Role, content: String, timestamp: String) -> Self {
Self {
role,
content,
timestamp,
}
}
pub fn user(content: String) -> Self {
let timestamp = Self::current_timestamp();
Self::new(Role::User, content, timestamp)
}
pub fn assistant(content: String) -> Self {
let timestamp = Self::current_timestamp();
Self::new(Role::Assistant, content, timestamp)
}
pub fn system(content: String) -> Self {
let timestamp = Self::current_timestamp();
Self::new(Role::System, content, timestamp)
}
fn current_timestamp() -> String {
chrono::Local::now().format("%H:%M").to_string()
}
}
#[derive(Debug, Clone, Copy)]
pub struct RenderPosition {
pub message_idx: usize,
pub line_idx: usize,
pub char_start: usize,
pub char_end: usize,
pub screen_row: u16,
}
#[derive(Debug, Clone)]
pub struct ChatView {
messages: Vec<Message>,
scroll_offset: usize,
pinned_to_bottom: bool,
last_max_scroll_offset: Cell<usize>,
highlighter: SyntaxHighlighter,
cached_height: Cell<usize>,
cache_dirty: Cell<bool>,
hidden_message_count: Cell<usize>,
selection_start: Option<(usize, usize)>,
selection_end: Option<(usize, usize)>,
render_positions: RefCell<Vec<RenderPosition>>,
}
impl Default for ChatView {
fn default() -> Self {
Self::new()
}
}
impl ChatView {
pub fn new() -> Self {
debug!(component = %"ChatView", "Component created");
Self {
messages: Vec::new(),
scroll_offset: 0,
pinned_to_bottom: true,
last_max_scroll_offset: Cell::new(0),
highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
cache_dirty: Cell::new(true),
cached_height: Cell::new(0),
hidden_message_count: Cell::new(0),
selection_start: None,
selection_end: None,
render_positions: RefCell::new(Vec::new()),
}
}
pub fn add_message(&mut self, message: Message) {
self.messages.push(message);
self.cache_dirty.set(true); self.scroll_to_bottom();
}
pub fn append_to_last_assistant(&mut self, content: &str) {
if content.is_empty() {
debug!("append_to_last_assistant: skipping empty content");
return;
}
let last_role = self
.messages
.last()
.map(|m| format!("{:?}", m.role))
.unwrap_or_else(|| "None".to_string());
debug!(
"append_to_last_assistant: content.len()={}, messages.count()={}, last_role={}",
content.len(),
self.messages.len(),
last_role
);
if let Some(last) = self.messages.last_mut() {
if matches!(last.role, Role::Assistant) {
debug!(
"append_to_last_assistant: appending to existing assistant message (content now {} chars)",
last.content.len() + content.len()
);
last.content.push_str(content);
self.cache_dirty.set(true); self.scroll_to_bottom();
return;
}
}
debug!(
"append_to_last_assistant: creating NEW assistant message with {} chars",
content.len()
);
self.add_message(Message::assistant(content.to_string()));
}
pub fn message_count(&self) -> usize {
self.messages.len()
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
pub fn scroll_up(&mut self) {
const SCROLL_LINES: usize = 5;
if self.pinned_to_bottom {
self.scroll_offset = self.last_max_scroll_offset.get();
self.pinned_to_bottom = false;
}
self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_LINES);
self.cache_dirty.set(true);
}
pub fn scroll_down(&mut self) {
const SCROLL_LINES: usize = 5;
let max_offset = self.last_max_scroll_offset.get();
if self.pinned_to_bottom {
self.scroll_offset = max_offset;
self.pinned_to_bottom = false;
return;
}
self.scroll_offset = (self.scroll_offset.saturating_add(SCROLL_LINES)).min(max_offset);
}
pub fn scroll_page_up(&mut self, viewport_height: u16) {
if self.pinned_to_bottom {
self.scroll_offset = self.last_max_scroll_offset.get();
self.pinned_to_bottom = false;
}
let page_size = viewport_height as usize;
self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
}
pub fn scroll_page_down(&mut self, viewport_height: u16) {
let max_offset = self.last_max_scroll_offset.get();
if self.pinned_to_bottom {
self.scroll_offset = max_offset;
self.pinned_to_bottom = false;
return;
}
let page_size = viewport_height as usize;
self.scroll_offset = (self.scroll_offset.saturating_add(page_size)).min(max_offset);
}
pub fn scroll_to_bottom(&mut self) {
self.pinned_to_bottom = true;
}
pub fn scroll_to_top(&mut self) {
self.pinned_to_bottom = false;
self.scroll_offset = 0;
}
pub fn start_selection(&mut self, message_idx: usize, byte_offset: usize) {
self.selection_start = Some((message_idx, byte_offset));
self.selection_end = Some((message_idx, byte_offset));
}
pub fn extend_selection(&mut self, message_idx: usize, byte_offset: usize) {
if self.selection_start.is_some() {
self.selection_end = Some((message_idx, byte_offset));
}
}
pub fn clear_selection(&mut self) {
self.selection_start = None;
self.selection_end = None;
}
pub fn has_selection(&self) -> bool {
self.selection_start.is_some() && self.selection_end.is_some()
}
pub fn screen_to_text_pos(&self, col: u16, row: u16) -> Option<(usize, usize)> {
debug!(
"screen_to_text_pos: col={}, row={}, positions={}",
col,
row,
self.render_positions.borrow().len()
);
for pos in self.render_positions.borrow().iter() {
debug!(
" checking pos.screen_row={} vs row={}",
pos.screen_row, row
);
if pos.screen_row == row {
let line_len = pos.char_end.saturating_sub(pos.char_start);
let char_in_line = (col as usize).min(line_len);
debug!(
" matched! msg_idx={}, char_offset={}",
pos.message_idx,
pos.char_start + char_in_line
);
return Some((pos.message_idx, pos.char_start + char_in_line));
}
}
debug!(" no match found");
None
}
pub fn render_position_count(&self) -> usize {
self.render_positions.borrow().len()
}
pub fn is_selected(&self, message_idx: usize, char_offset: usize) -> bool {
let Some((start_msg, start_offset)) = self.selection_start else {
return false;
};
let Some((end_msg, end_offset)) = self.selection_end else {
return false;
};
let (min_msg, min_offset, max_msg, max_offset) =
if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
(start_msg, start_offset, end_msg, end_offset)
} else {
(end_msg, end_offset, start_msg, start_offset)
};
if message_idx < min_msg || message_idx > max_msg {
return false;
}
if message_idx == min_msg && message_idx == max_msg {
char_offset >= min_offset && char_offset < max_offset
} else if message_idx == min_msg {
char_offset >= min_offset
} else if message_idx == max_msg {
char_offset < max_offset
} else {
true
}
}
fn apply_selection_highlight<'a>(
&self,
text: &'a str,
message_idx: usize,
line_char_start: usize,
base_style: Style,
) -> Vec<Span<'a>> {
let selection_style = Style::default().bg(Color::Blue).fg(Color::White);
if !self.has_selection() {
return vec![Span::styled(text, base_style)];
}
let mut spans = Vec::new();
let mut current_start = 0;
let mut in_selection = false;
let char_positions: Vec<(usize, char)> = text.char_indices().collect();
for (i, (byte_idx, _)) in char_positions.iter().enumerate() {
let global_char = line_char_start + i;
let is_sel = self.is_selected(message_idx, global_char);
if is_sel != in_selection {
if i > current_start {
let segment_byte_start = char_positions[current_start].0;
let segment_byte_end = *byte_idx;
let segment = &text[segment_byte_start..segment_byte_end];
let style = if in_selection {
selection_style
} else {
base_style
};
spans.push(Span::styled(segment, style));
}
current_start = i;
in_selection = is_sel;
}
}
if current_start < char_positions.len() {
let segment_byte_start = char_positions[current_start].0;
let segment = &text[segment_byte_start..];
let style = if in_selection {
selection_style
} else {
base_style
};
spans.push(Span::styled(segment, style));
}
if spans.is_empty() {
vec![Span::styled(text, base_style)]
} else {
spans
}
}
pub fn get_selected_text(&self) -> Option<String> {
let (start_msg, start_offset) = self.selection_start?;
let (end_msg, end_offset) = self.selection_end?;
let (min_msg, min_offset, max_msg, max_offset) =
if start_msg < end_msg || (start_msg == end_msg && start_offset <= end_offset) {
(start_msg, start_offset, end_msg, end_offset)
} else {
(end_msg, end_offset, start_msg, start_offset)
};
if min_msg == max_msg {
let msg = self.messages.get(min_msg)?;
let content = &msg.content;
let start_byte = char_offset_to_byte(content, min_offset);
let end_byte = char_offset_to_byte(content, max_offset);
if start_byte < content.len() && end_byte <= content.len() {
Some(content[start_byte..end_byte].to_string())
} else {
None
}
} else {
let mut result = String::new();
if let Some(msg) = self.messages.get(min_msg) {
let start_byte = char_offset_to_byte(&msg.content, min_offset);
if start_byte < msg.content.len() {
result.push_str(&msg.content[start_byte..]);
}
}
for idx in (min_msg + 1)..max_msg {
if let Some(msg) = self.messages.get(idx) {
result.push('\n');
result.push_str(&msg.content);
}
}
if let Some(msg) = self.messages.get(max_msg) {
result.push('\n');
let end_byte = char_offset_to_byte(&msg.content, max_offset);
if end_byte > 0 && end_byte <= msg.content.len() {
result.push_str(&msg.content[..end_byte]);
}
}
Some(result)
}
}
pub fn clear(&mut self) {
self.messages.clear();
self.scroll_offset = 0;
self.pinned_to_bottom = true;
self.cache_dirty.set(true);
self.hidden_message_count.set(0);
}
fn get_render_window(&self) -> (&[Message], usize) {
let total_count = self.messages.len();
if self.pinned_to_bottom && total_count > RENDER_WINDOW_SIZE {
let hidden_count = total_count.saturating_sub(RENDER_WINDOW_SIZE);
let window = &self.messages[hidden_count..];
self.hidden_message_count.set(hidden_count);
(window, hidden_count)
} else {
self.hidden_message_count.set(0);
(&self.messages, 0)
}
}
fn estimate_line_count(text: &str, width: usize) -> usize {
if width == 0 {
return 0;
}
let mut lines = 0;
let mut current_line_len = 0;
for line in text.lines() {
if line.is_empty() {
lines += 1;
current_line_len = 0;
continue;
}
let words: Vec<&str> = line.split_whitespace().collect();
let mut word_index = 0;
while word_index < words.len() {
let word = words[word_index];
let word_len = word.len();
if current_line_len == 0 {
if word_len > width {
let mut chars_left = word;
while !chars_left.is_empty() {
let take = chars_left.len().min(width);
lines += 1;
chars_left = &chars_left[take..];
}
current_line_len = 0;
} else {
current_line_len = word_len;
}
} else if current_line_len + 1 + word_len <= width {
current_line_len += 1 + word_len;
} else {
lines += 1;
current_line_len = if word_len > width {
let mut chars_left = word;
while !chars_left.is_empty() {
let take = chars_left.len().min(width);
lines += 1;
chars_left = &chars_left[take..];
}
0
} else {
word_len
};
}
word_index += 1;
}
if current_line_len > 0 || words.is_empty() {
lines += 1;
}
current_line_len = 0;
}
lines.max(1)
}
fn process_code_blocks(&self, content: &str) -> Vec<(String, LineType, bool, Option<String>)> {
let mut result = Vec::new();
let lines = content.lines().peekable();
let mut in_code_block = false;
let mut current_lang: Option<String> = None;
for line in lines {
if line.starts_with("```") {
if in_code_block {
in_code_block = false;
current_lang = None;
} else {
in_code_block = true;
current_lang = line
.strip_prefix("```")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
}
} else if in_code_block {
result.push((
line.to_string(),
LineType::CodeBlock,
true,
current_lang.clone(),
));
} else {
let (line_type, _) = detect_line_type(line);
result.push((line.to_string(), line_type, false, None));
}
}
result
}
fn calculate_total_height(&self, width: u16) -> usize {
if !self.cache_dirty.get() {
return self.cached_height.get();
}
let mut total_height = 0;
for message in &self.messages {
total_height += 1;
let processed = self.process_code_blocks(&message.content);
for (line, _line_type, _is_code, _lang) in processed {
let line_height = if _is_code {
1 } else {
Self::estimate_line_count(&line, width as usize)
};
total_height += line_height;
}
total_height += 1;
}
self.cached_height.set(total_height);
self.cache_dirty.set(false);
total_height
}
fn render_to_buffer(&self, area: Rect, buf: &mut Buffer) {
if self.cache_dirty.get() {
self.render_positions.borrow_mut().clear();
}
let total_height = self.calculate_total_height(area.width);
let viewport_height = area.height as usize;
let max_scroll_offset = if total_height > viewport_height {
total_height.saturating_sub(viewport_height)
} else {
0
};
self.last_max_scroll_offset.set(max_scroll_offset);
let scroll_offset = if self.pinned_to_bottom {
max_scroll_offset
} else {
self.scroll_offset.min(max_scroll_offset)
};
let (initial_y_offset, skip_until, max_y) =
(area.y, scroll_offset, scroll_offset + viewport_height);
let mut y_offset = initial_y_offset;
let mut global_y: usize = 0;
let (messages_to_render, hidden_count) = self.get_render_window();
if hidden_count > 0 {
for message in &self.messages[..hidden_count] {
let role_height = 1;
let processed = self.process_code_blocks(&message.content);
let content_height: usize = processed
.iter()
.map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
.sum();
let separator_height = 1;
global_y += role_height + content_height + separator_height;
}
}
for (local_msg_idx, message) in messages_to_render.iter().enumerate() {
let message_idx = hidden_count + local_msg_idx;
let role_height = 1;
let processed = self.process_code_blocks(&message.content);
let content_height: usize = processed
.iter()
.map(|(line, _, _, _)| Self::estimate_line_count(line, area.width as usize))
.sum();
let separator_height = 1;
let message_height = role_height + content_height + separator_height;
if global_y + message_height <= skip_until {
global_y += message_height;
continue;
}
if global_y >= max_y {
break;
}
if global_y >= skip_until && y_offset < area.y + area.height {
let role_text = format!("[{}] {}", message.role.display_name(), message.timestamp);
let style = Style::default()
.fg(message.role.badge_color())
.add_modifier(Modifier::BOLD);
let line = Line::from(vec![Span::styled(role_text, style)]);
Paragraph::new(line)
.wrap(Wrap { trim: false })
.render(Rect::new(area.x, y_offset, area.width, 1), buf);
y_offset += 1;
}
global_y += 1;
let mut char_offset: usize = 0;
for (line_idx, (line, line_type, is_code_block, lang)) in processed.iter().enumerate() {
let line_height = Self::estimate_line_count(line, area.width as usize);
let line_char_count = line.chars().count();
if global_y >= skip_until && y_offset < area.y + area.height {
self.render_positions.borrow_mut().push(RenderPosition {
message_idx,
line_idx,
char_start: char_offset,
char_end: char_offset + line_char_count,
screen_row: y_offset, });
}
if *is_code_block && global_y >= skip_until {
if let Some(ref lang_str) = lang {
if let Ok(highlighted_spans) = self
.highlighter
.highlight_to_spans(&format!("{}\n", line), lang_str)
{
for highlighted_line in highlighted_spans {
if y_offset < area.y + area.height && global_y < max_y {
let text = Text::from(Line::from(highlighted_line));
Paragraph::new(text)
.wrap(Wrap { trim: false })
.render(Rect::new(area.x, y_offset, area.width, 1), buf);
y_offset += 1;
}
global_y += 1;
if global_y >= max_y {
break;
}
}
continue;
}
}
}
let base_style = line_type.style();
let spans = if self.has_selection() {
self.apply_selection_highlight(line, message_idx, char_offset, base_style)
} else {
parse_inline_markdown(line, base_style)
};
let text_line = Line::from(spans);
if global_y >= skip_until && y_offset < area.y + area.height {
let render_height =
line_height.min((area.y + area.height - y_offset) as usize) as u16;
Paragraph::new(text_line)
.wrap(Wrap { trim: false })
.render(Rect::new(area.x, y_offset, area.width, render_height), buf);
y_offset += line_height as u16;
}
global_y += line_height;
char_offset += line_char_count + 1;
if global_y >= max_y {
break;
}
}
if global_y >= skip_until && global_y < max_y && y_offset < area.y + area.height {
Paragraph::new("─".repeat(area.width as usize).as_str())
.style(Style::default().fg(Color::DarkGray))
.render(Rect::new(area.x, y_offset, area.width, 1), buf);
y_offset += 1;
}
global_y += 1;
}
}
}
impl ratatui::widgets::Widget for &ChatView {
fn render(self, area: Rect, buf: &mut Buffer) {
(*self).render_to_buffer(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_role_display_name() {
assert_eq!(Role::User.display_name(), "USER");
assert_eq!(Role::Assistant.display_name(), "ASSISTANT");
assert_eq!(Role::System.display_name(), "SYSTEM");
}
#[test]
fn test_role_badge_color() {
assert_eq!(Role::User.badge_color(), Color::Blue);
assert_eq!(Role::Assistant.badge_color(), Color::Green);
assert_eq!(Role::System.badge_color(), Color::Yellow);
}
#[test]
fn test_message_new() {
let message = Message::new(Role::User, "Hello, World!".to_string(), "12:34".to_string());
assert_eq!(message.role, Role::User);
assert_eq!(message.content, "Hello, World!");
assert_eq!(message.timestamp, "12:34");
}
#[test]
fn test_message_user() {
let message = Message::user("Test message".to_string());
assert_eq!(message.role, Role::User);
assert_eq!(message.content, "Test message");
assert!(!message.timestamp.is_empty());
}
#[test]
fn test_message_assistant() {
let message = Message::assistant("Response".to_string());
assert_eq!(message.role, Role::Assistant);
assert_eq!(message.content, "Response");
assert!(!message.timestamp.is_empty());
}
#[test]
fn test_message_system() {
let message = Message::system("System notification".to_string());
assert_eq!(message.role, Role::System);
assert_eq!(message.content, "System notification");
assert!(!message.timestamp.is_empty());
}
#[test]
fn test_chat_view_new() {
let chat = ChatView::new();
assert_eq!(chat.message_count(), 0);
assert_eq!(chat.scroll_offset, 0);
assert!(chat.messages().is_empty());
}
#[test]
fn test_chat_view_default() {
let chat = ChatView::default();
assert_eq!(chat.message_count(), 0);
assert_eq!(chat.scroll_offset, 0);
}
#[test]
fn test_chat_view_add_message() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Hello".to_string()));
assert_eq!(chat.message_count(), 1);
chat.add_message(Message::assistant("Hi there!".to_string()));
assert_eq!(chat.message_count(), 2);
}
#[test]
fn test_chat_view_add_multiple_messages() {
let mut chat = ChatView::new();
for i in 0..5 {
chat.add_message(Message::user(format!("Message {}", i)));
}
assert_eq!(chat.message_count(), 5);
}
#[test]
fn test_chat_view_scroll_up() {
let mut chat = ChatView::new();
for i in 0..10 {
chat.add_message(Message::user(format!("Message {}", i)));
}
assert!(chat.pinned_to_bottom);
chat.scroll_up();
assert!(!chat.pinned_to_bottom);
}
#[test]
fn test_chat_view_scroll_up_bounds() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Test".to_string()));
chat.scroll_to_top();
chat.scroll_up();
assert_eq!(chat.scroll_offset, 0);
assert!(!chat.pinned_to_bottom);
chat.scroll_up();
assert_eq!(chat.scroll_offset, 0);
}
#[test]
fn test_chat_view_scroll_down() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Test".to_string()));
assert!(chat.pinned_to_bottom);
chat.scroll_down();
assert!(!chat.pinned_to_bottom);
for i in 0..20 {
chat.add_message(Message::user(format!("Message {}", i)));
}
chat.last_max_scroll_offset.set(100);
chat.scroll_to_bottom(); assert!(chat.pinned_to_bottom);
chat.scroll_up();
assert!(!chat.pinned_to_bottom);
assert_eq!(chat.scroll_offset, 95);
chat.scroll_down();
assert!(!chat.pinned_to_bottom);
assert_eq!(chat.scroll_offset, 100);
chat.scroll_down();
assert_eq!(chat.scroll_offset, 100); }
#[test]
fn test_chat_view_scroll_to_bottom() {
let mut chat = ChatView::new();
for i in 0..5 {
chat.add_message(Message::user(format!("Message {}", i)));
}
chat.scroll_to_top();
assert_eq!(chat.scroll_offset, 0);
assert!(!chat.pinned_to_bottom);
chat.scroll_to_bottom();
assert!(chat.pinned_to_bottom);
}
#[test]
fn test_chat_view_scroll_to_top() {
let mut chat = ChatView::new();
for i in 0..5 {
chat.add_message(Message::user(format!("Message {}", i)));
}
chat.scroll_to_bottom();
assert!(chat.pinned_to_bottom);
chat.scroll_to_top();
assert_eq!(chat.scroll_offset, 0);
assert!(!chat.pinned_to_bottom);
}
#[test]
fn test_chat_view_auto_scroll() {
let mut chat = ChatView::new();
for i in 0..5 {
chat.add_message(Message::user(format!("Message {}", i)));
}
assert!(chat.pinned_to_bottom);
}
#[test]
fn test_chat_view_render() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Test message".to_string()));
let area = Rect::new(0, 0, 50, 20);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert!(!cell.symbol().is_empty());
}
#[test]
fn test_chat_view_render_multiple_messages() {
let mut chat = ChatView::new();
chat.add_message(Message::user("First message".to_string()));
chat.add_message(Message::assistant("Second message".to_string()));
chat.add_message(Message::system("System message".to_string()));
let area = Rect::new(0, 0, 50, 20);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
}
#[test]
fn test_chat_view_render_with_long_message() {
let mut chat = ChatView::new();
let long_message = "This is a very long message that should wrap across multiple lines in the buffer when rendered. ".repeat(5);
chat.add_message(Message::user(long_message));
let area = Rect::new(0, 0, 30, 20);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
}
#[test]
fn test_chat_view_messages_ref() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Message 1".to_string()));
chat.add_message(Message::assistant("Message 2".to_string()));
let messages = chat.messages();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].content, "Message 1");
assert_eq!(messages[1].content, "Message 2");
}
#[test]
fn test_calculate_total_height() {
let mut chat = ChatView::new();
assert_eq!(chat.calculate_total_height(50), 0);
chat.add_message(Message::user("Hello".to_string()));
assert_eq!(chat.calculate_total_height(50), 3);
}
#[test]
fn test_calculate_total_height_with_wrapping() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Hi".to_string()));
assert_eq!(chat.calculate_total_height(50), 3);
let long_msg = "This is a very long message that will definitely wrap onto multiple lines when displayed in a narrow container".to_string();
chat.add_message(Message::assistant(long_msg));
let height = chat.calculate_total_height(20);
assert!(height > 6); }
#[test]
fn test_short_content_pinned_to_bottom_should_start_at_top() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Hello".to_string()));
let area = Rect::new(0, 0, 50, 20);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
let cell = buffer.cell((0, 0)).unwrap();
assert!(
!cell.symbol().is_empty(),
"Content should start at top, not be pushed down"
);
}
#[test]
fn test_streaming_content_stays_pinned() {
let mut chat = ChatView::new();
chat.add_message(Message::assistant("Start".to_string()));
let area = Rect::new(0, 0, 50, 20);
let mut buffer1 = Buffer::empty(area);
chat.render(area, &mut buffer1);
chat.append_to_last_assistant(" and continue with more text that is longer");
let mut buffer2 = Buffer::empty(area);
chat.render(area, &mut buffer2);
let has_content_near_bottom = (0u16..20).any(|y| {
let c = buffer2.cell((0, y)).unwrap();
!c.symbol().is_empty() && c.symbol() != "│" && c.symbol() != " "
});
assert!(
has_content_near_bottom,
"Content should remain visible near bottom when pinned"
);
}
#[test]
fn test_content_shorter_than_viewport_no_excess_padding() {
let mut chat = ChatView::new();
chat.add_message(Message::user("Short message".to_string()));
let total_height = chat.calculate_total_height(50);
let viewport_height: u16 = 20;
assert!(
total_height < viewport_height as usize,
"Content should be shorter than viewport"
);
let area = Rect::new(0, 0, 50, viewport_height);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
let mut first_content_y: Option<u16> = None;
for y in 0..viewport_height {
let cell = buffer.cell((0, y)).unwrap();
let is_border = matches!(
cell.symbol(),
"─" | "│" | "┌" | "┐" | "└" | "┘" | "├" | "┤" | "┬" | "┴"
);
if !is_border && !cell.symbol().is_empty() {
first_content_y = Some(y);
break;
}
}
let first_content_y = first_content_y.expect("Should find content somewhere");
assert_eq!(
first_content_y, 0,
"Content should start at y=0, not be pushed down by padding"
);
}
#[test]
fn test_pinned_state_after_scrolling() {
let mut chat = ChatView::new();
for i in 0..10 {
chat.add_message(Message::user(format!("Message {}", i)));
}
assert!(chat.pinned_to_bottom);
chat.scroll_up();
assert!(!chat.pinned_to_bottom);
chat.scroll_to_bottom();
assert!(chat.pinned_to_bottom);
}
#[test]
fn test_message_growth_maintains_correct_position() {
let mut chat = ChatView::new();
chat.add_message(Message::assistant("Initial".to_string()));
let area = Rect::new(0, 0, 60, 10);
let mut buffer = Buffer::empty(area);
chat.render(area, &mut buffer);
chat.append_to_last_assistant(" content that gets added");
let mut buffer2 = Buffer::empty(area);
chat.render(area, &mut buffer2);
assert!(
chat.pinned_to_bottom,
"Should remain pinned after content growth"
);
}
}