claw-code/rust/crates/rusty-claude-cli/src/tui/components/conversation.rs

454 lines
16 KiB
Rust

//! Conversation pane component.
//!
//! Renders the left-pane conversation view with word-wrapping, scroll,
//! markdown content, and diff highlighting.
use std::cell::RefCell;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use unicode_width::UnicodeWidthChar;
use crate::keybindings::KeyMap;
use crate::theme::TuiTheme;
use crate::tui::component::Component;
use crate::tui::event::TuiEvent;
use crate::tui::legacy::{ConversationContent, ConversationLine};
use crate::tui::markdown::render_diff;
use crate::tui::markdown::ratatui_renderer::MarkdownRenderer;
const MAX_CONVERSATION_LINES: usize = 10_000;
/// Render cache — built during the update phase, read during render.
struct RenderCache {
wrapped_lines: Vec<Line<'static>>,
expand_counts: Vec<usize>,
built_width: u16,
}
impl Default for RenderCache {
fn default() -> Self {
Self {
wrapped_lines: Vec::new(),
expand_counts: Vec::new(),
built_width: 0,
}
}
}
/// Conversation pane — the main content area of the TUI.
pub struct ConversationPane {
conversation: Vec<ConversationLine>,
scroll: u16,
dirty: bool,
renderer: MarkdownRenderer,
/// Interior-mutability cache so `render(&self)` can rebuild if needed.
cache: RefCell<RenderCache>,
}
impl ConversationPane {
pub fn new(theme: TuiTheme) -> Self {
Self {
conversation: Vec::new(),
scroll: 0,
dirty: true,
renderer: MarkdownRenderer::new(theme),
cache: RefCell::new(RenderCache::default()),
}
}
pub fn set_theme(&mut self, theme: TuiTheme) {
self.renderer.set_theme(theme);
self.dirty = true;
}
// -------------------------------------------------------------------
// Content mutators
// -------------------------------------------------------------------
pub fn push_banner(&mut self, text: String, color: Color) {
self.conversation.push(ConversationLine::plain(text, color, true));
self.auto_scroll();
}
pub fn push_user_input(&mut self, text: &str, color: Color) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, true));
}
self.auto_scroll();
}
pub fn push_system_message(&mut self, text: &str, color: Color) {
for raw_line in text.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
self.auto_scroll();
}
pub fn push_output(&mut self, text: &str, is_error: bool, theme: &TuiTheme) {
if text.is_empty() {
return;
}
let clean = crate::tui_update::strip_ansi(text);
if is_error {
let color = theme.conversation_error.to_color();
for raw_line in clean.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
} else if crate::tui::markdown::looks_like_markdown(&clean) {
self.conversation.push(ConversationLine::markdown(clean));
} else {
let color = theme.conversation_text.to_color();
for raw_line in clean.lines() {
self.conversation.push(ConversationLine::plain(raw_line.to_string(), color, false));
}
}
self.auto_scroll();
}
pub fn push_diff(&mut self, diff: &str) {
self.conversation.push(ConversationLine::diff(diff.to_string()));
self.auto_scroll();
}
pub fn clear(&mut self) {
self.conversation.clear();
self.scroll = 0;
self.dirty = true;
}
pub fn scroll_up(&mut self, amount: u16) {
self.scroll = self.scroll.saturating_add(amount);
self.dirty = true;
}
pub fn scroll_down(&mut self, amount: u16) {
self.scroll = self.scroll.saturating_sub(amount);
self.dirty = true;
}
pub fn scroll_top(&mut self) {
self.scroll = u16::MAX;
self.dirty = true;
}
pub fn scroll_bottom(&mut self) {
self.scroll = 0;
self.dirty = true;
}
fn auto_scroll(&mut self) {
if self.conversation.len() > MAX_CONVERSATION_LINES {
let drain_count = self.conversation.len() - MAX_CONVERSATION_LINES;
self.conversation.drain(..drain_count);
}
self.scroll = 0;
self.dirty = true;
}
/// Rebuild the wrapped-line cache if needed.
fn rebuild_cache(&self, width: u16, theme: &TuiTheme) {
let mut cache = self.cache.borrow_mut();
if !self.dirty && cache.built_width == width {
return;
}
let content_width = (width as usize).saturating_sub(2);
let (wrapped, expand_counts) = build_wrapped_conversation(
&self.conversation,
content_width,
&self.renderer,
theme,
);
cache.wrapped_lines = wrapped;
cache.expand_counts = expand_counts;
cache.built_width = width;
// Note: can't clear self.dirty here because we borrow self.cache
// and self.dirty is on self. The caller (render) will handle this.
}
/// Clear the dirty flag after a successful cache rebuild + render.
pub fn mark_clean(&mut self) {
self.dirty = false;
}
}
impl Component for ConversationPane {
fn render(&self, area: Rect, frame: &mut Frame, theme: &TuiTheme) {
// Rebuild cache if dirty or width changed
self.rebuild_cache(area.width, theme);
let cache = self.cache.borrow();
let pane_rows = (area.height.saturating_sub(1) as usize).max(1);
let total_visual = cache.wrapped_lines.len();
let scroll = self.scroll as usize;
let max_offset = total_visual.saturating_sub(pane_rows);
let offset = scroll.min(max_offset);
let start = total_visual.saturating_sub(pane_rows + offset);
let visible: Vec<Line> = cache.wrapped_lines.iter().skip(start).take(pane_rows).cloned().collect();
let conversation_widget = Paragraph::new(visible).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.border.to_color()))
.title(Span::styled(
" Conversation ",
Style::default().fg(theme.border.to_color()),
)),
);
frame.render_widget(conversation_widget, area);
if total_visual > pane_rows {
let scroll_label = format!(" {}/{} ", offset, max_offset);
let scroll_area = Rect {
x: area.x + area.width.saturating_sub(scroll_label.len() as u16 + 1),
y: area.y,
width: scroll_label.len() as u16 + 1,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(scroll_label, Style::default().fg(theme.conversation_dim.to_color()))),
scroll_area,
);
}
}
fn is_dirty(&self) -> bool {
self.dirty
}
}
// ---------------------------------------------------------------------------
// Word-wrap + build helpers
// ---------------------------------------------------------------------------
fn wrap_line<'a>(text: &str, width: usize, style: Style) -> Vec<Line<'a>> {
if width == 0 {
return vec![Line::from(Span::styled(text.to_string(), style))];
}
let mut result: Vec<Line<'a>> = Vec::new();
if text.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
return result;
}
let mut current_line = String::new();
let mut current_width: usize = 0;
let mut last_break_byte: usize = 0;
for ch in text.chars() {
let cw = ch.width().unwrap_or(0);
if current_width + cw > width && !current_line.is_empty() {
if last_break_byte > 0 {
let keep = current_line[..last_break_byte].trim_end().to_string();
let rest = current_line[last_break_byte..].to_string();
result.push(Line::from(Span::styled(keep, style.clone())));
current_width = rest.chars().map(|c| c.width().unwrap_or(0)).sum();
current_line = rest;
last_break_byte = 0;
} else {
result.push(Line::from(Span::styled(current_line.clone(), style.clone())));
current_line.clear();
current_width = 0;
last_break_byte = 0;
}
}
current_line.push(ch);
current_width += cw;
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
last_break_byte = current_line.len();
}
}
if !current_line.is_empty() {
result.push(Line::from(Span::styled(current_line, style)));
}
if result.is_empty() {
result.push(Line::from(Span::styled(String::new(), style)));
}
result
}
fn build_wrapped_conversation<'a>(
conversation: &[ConversationLine],
content_width: usize,
markdown_renderer: &MarkdownRenderer,
theme: &TuiTheme,
) -> (Vec<Line<'a>>, Vec<usize>) {
let mut all_lines: Vec<Line<'a>> = Vec::new();
let mut expand_counts: Vec<usize> = Vec::new();
for cl in conversation {
match &cl.content {
ConversationContent::Plain { text, color, bold } => {
let mut style = Style::default().fg(*color);
if *bold { style = style.add_modifier(Modifier::BOLD); }
let wrapped = wrap_line(text, content_width, style);
let count = wrapped.len().max(1);
expand_counts.push(count);
all_lines.extend(wrapped);
}
ConversationContent::Markdown { source } => {
let rendered = markdown_renderer.render(source, content_width as u16);
let count = rendered.len().max(1);
expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
ConversationContent::CodeDiff { diff } => {
let rendered = render_diff(diff, theme);
let count = rendered.len().max(1);
expand_counts.push(count);
all_lines.extend(rendered.into_iter().map(|l: Line<'static>| {
Line::from(l.spans.into_iter().map(|s| {
Span::styled(s.content.into_owned(), s.style)
}).collect::<Vec<_>>())
}));
}
}
}
(all_lines, expand_counts)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_theme() -> TuiTheme {
TuiTheme::builtin("default").unwrap()
}
#[test]
fn test_wrap_empty() {
let lines = wrap_line("", 80, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_exact_width() {
let lines = wrap_line("hello", 5, Style::default());
assert_eq!(lines.len(), 1);
}
#[test]
fn test_wrap_overflow_with_break() {
let lines = wrap_line("hello world foo bar", 10, Style::default());
assert!(lines.len() >= 2);
}
#[test]
fn test_conversation_pane_push_and_scroll() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme.clone());
pane.push_user_input("Hello", theme.conversation_user.to_color());
assert_eq!(pane.conversation.len(), 1);
pane.scroll_up(5);
assert_eq!(pane.scroll, 5);
pane.scroll_bottom();
assert_eq!(pane.scroll, 0);
}
#[test]
fn test_conversation_pane_clear() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme);
pane.push_system_message("test", Color::Yellow);
assert!(!pane.conversation.is_empty());
pane.clear();
assert!(pane.conversation.is_empty());
}
#[test]
fn test_cache_rebuilds_on_dirty() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme.clone());
pane.push_user_input("Hello world", theme.conversation_user.to_color());
assert!(pane.dirty);
// Rebuild cache (simulating what render() does)
pane.rebuild_cache(80, &theme);
let cache = pane.cache.borrow();
assert!(!cache.wrapped_lines.is_empty());
assert_eq!(cache.built_width, 80);
}
// -------------------------------------------------------------------
// Regression tests for word-wrap bounds and output bleed bugs
// -------------------------------------------------------------------
/// Regression: wrap_line output should never exceed the given width
/// in unicode-width terms. Bug: text wrapped outside the conversation
/// pane, causing output to bleed across the terminal.
#[test]
fn test_wrap_line_respects_width() {
// Long string with no natural break points
let long_str = "abcdefghij".repeat(20); // 200 chars, no spaces
let width = 40;
let lines = wrap_line(&long_str, width, Style::default());
for line in &lines {
let line_width: usize = line.spans.iter()
.map(|span| {
span.content.chars().map(|c| c.width().unwrap_or(0)).sum::<usize>()
})
.sum();
assert!(
line_width <= width,
"Wrapped line width {line_width} exceeds target {width}: {:?}",
line.spans.iter().map(|s| s.content.clone()).collect::<Vec<_>>()
);
}
}
/// Regression: narrow width doesn't cause infinite loop or zero-width output.
#[test]
fn test_wrap_line_narrow_width() {
let lines = wrap_line("hello world foo bar", 1, Style::default());
assert!(!lines.is_empty(), "Even with width=1, must produce lines");
// With width=1, each char gets its own line
let lines = wrap_line("ab", 1, Style::default());
assert!(lines.len() >= 2, "2 chars at width=1 should produce >= 2 lines");
}
/// Regression: cache is invalidated when width changes.
/// Bug: conversation didn't re-wrap after terminal resize.
#[test]
fn test_cache_invalidated_on_width_change() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme.clone());
pane.push_user_input("Hello world this is a test", theme.conversation_user.to_color());
// Build at width 80
pane.rebuild_cache(80, &theme);
let cache_lines_80 = pane.cache.borrow().wrapped_lines.len();
// Resize to 40 — should invalidate and re-wrap
pane.dirty = true; // width change would set dirty in render()
pane.rebuild_cache(40, &theme);
let cache_lines_40 = pane.cache.borrow().wrapped_lines.len();
// Narrower width should produce more wrapped lines
assert!(
cache_lines_40 >= cache_lines_80,
"Narrower width ({cache_lines_40} lines) should produce >= as many lines as wider ({cache_lines_80} lines)"
);
}
/// Regression: mark_clean clears the dirty flag.
#[test]
fn test_mark_clean_clears_dirty() {
let theme = test_theme();
let mut pane = ConversationPane::new(theme);
pane.push_system_message("test", Color::Yellow);
assert!(pane.dirty);
pane.mark_clean();
assert!(!pane.dirty);
}
}