use std::hash::{Hash, Hasher};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, StatefulWidget, Widget},
};
use rustc_hash::FxHashMap;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::domain::{
ActionDetails, ActionDisplay, ActionResult, QuestionAnswer, ToolMetadata, format_compact_count,
};
use crate::models::ChatMessageKind;
use crate::models::{ChatMessage, MessageRole};
use crate::render::diff::{DiffLineKind, parse_diff_line};
use crate::render::markdown::parse_markdown;
use crate::render::theme::Theme;
use crate::utils::format_relative_timestamp;
#[derive(Debug, Clone)]
pub struct ImageClickTarget {
pub message_index: usize,
pub image_index: usize,
pub image_number: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ChatState {
scroll_offset: u16,
is_user_scrolling: bool,
pub image_click_map: Vec<(u16, ImageClickTarget)>,
pub last_scroll_position: u16,
pub last_chat_area: Option<(u16, u16, u16, u16)>, selection: Option<((usize, usize), (usize, usize))>,
last_rendered_rows: Vec<String>,
frame_memo: Option<FrameMemo>,
}
#[derive(Debug, Clone)]
struct FrameMemo {
key: u64,
lines: Vec<Line<'static>>,
click_map: Vec<(u16, ImageClickTarget)>,
}
impl ChatState {
pub fn new() -> Self {
Self {
scroll_offset: 0,
is_user_scrolling: false,
image_click_map: Vec::new(),
last_scroll_position: 0,
last_chat_area: None,
selection: None,
last_rendered_rows: Vec::new(),
frame_memo: None,
}
}
pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
let max_scroll = content_height.saturating_sub(viewport_height);
if self.is_user_scrolling {
let capped_offset = self.scroll_offset.min(max_scroll);
max_scroll.saturating_sub(capped_offset)
} else {
max_scroll
}
}
pub fn scroll_up(&mut self, amount: u16) {
self.is_user_scrolling = true;
self.scroll_offset = self.scroll_offset.saturating_add(amount);
self.selection = None;
}
pub fn scroll_down(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
if self.scroll_offset == 0 {
self.is_user_scrolling = false;
}
self.selection = None;
}
pub fn resume_auto_scroll(&mut self) {
self.is_user_scrolling = false;
self.scroll_offset = 0;
}
pub fn is_manually_scrolling(&self) -> bool {
self.is_user_scrolling
}
pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
let (_, area_y, _, area_height) = self.last_chat_area?;
if screen_row < area_y || screen_row >= area_y + area_height {
return None;
}
let viewport_row = screen_row - area_y;
let content_line = viewport_row + self.last_scroll_position;
self.image_click_map
.iter()
.find(|(line, _)| *line == content_line)
.map(|(_, target)| target)
}
fn screen_to_content(&self, screen_row: u16, screen_col: u16) -> Option<(usize, usize)> {
let (area_x, area_y, _, area_height) = self.last_chat_area?;
if screen_row < area_y || screen_row >= area_y + area_height {
return None;
}
let content_line = (screen_row - area_y) as usize + self.last_scroll_position as usize;
let col = screen_col.saturating_sub(area_x) as usize;
Some((content_line, col))
}
pub fn begin_selection(&mut self, screen_row: u16, screen_col: u16) {
self.selection = self
.screen_to_content(screen_row, screen_col)
.map(|p| (p, p));
}
pub fn update_selection(&mut self, screen_row: u16, screen_col: u16) {
if let Some((anchor, _)) = self.selection
&& let Some(cursor) = self.screen_to_content(screen_row, screen_col)
{
self.selection = Some((anchor, cursor));
}
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
pub fn selected_text(&self) -> Option<String> {
let (a, b) = self.selection?;
let (start, end) = if a <= b { (a, b) } else { (b, a) };
if self.last_rendered_rows.is_empty() {
return None;
}
let last = self.last_rendered_rows.len() - 1;
let (start_line, start_col) = (start.0.min(last), start.1);
let (end_line, end_col) = (end.0.min(last), end.1);
let mut out = String::new();
for line in start_line..=end_line {
let row = &self.last_rendered_rows[line];
let c0 = if line == start_line { start_col } else { 0 };
let c1 = if line == end_line {
end_col
} else {
usize::MAX
};
let mut piece = slice_by_cells(row, c0, c1).to_string();
let mut margin = SELECT_MARGIN_CELLS.saturating_sub(c0);
while margin > 0 && piece.starts_with(' ') {
piece.remove(0);
margin -= 1;
}
out.push_str(piece.trim_end());
if line != end_line {
out.push('\n');
}
}
if out.is_empty() { None } else { Some(out) }
}
}
const SELECT_MARGIN_CELLS: usize = 2;
fn wrap_preformatted(line: Line<'static>, width: usize, indent: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![line];
}
let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total <= width {
return vec![line];
}
let base = line.style;
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
let mut on_first = true;
for span in line.spans {
let style = span.style;
let mut buf = String::new();
for ch in span.content.chars() {
let cw = ch.width().unwrap_or(0);
let floor = if on_first { 0 } else { indent };
if cur_w + cw > width && cur_w > floor {
if !buf.is_empty() {
cur.push(Span::styled(std::mem::take(&mut buf), style));
}
out.push(Line::from(std::mem::take(&mut cur)).style(base));
on_first = false;
cur.push(Span::styled(" ".repeat(indent), base));
cur_w = indent;
}
buf.push(ch);
cur_w += cw;
}
if !buf.is_empty() {
cur.push(Span::styled(buf, style));
}
}
if !cur.is_empty() {
out.push(Line::from(cur).style(base));
}
if out.is_empty() {
vec![Line::from("").style(base)]
} else {
out
}
}
fn byte_at_cell(s: &str, target: usize) -> usize {
if target == 0 {
return 0;
}
let mut width = 0usize;
for (idx, ch) in s.char_indices() {
if width >= target {
return idx;
}
width += ch.width().unwrap_or(0);
}
s.len()
}
fn slice_by_cells(s: &str, c0: usize, c1: usize) -> &str {
let start = byte_at_cell(s, c0);
let end = byte_at_cell(s, c1).max(start);
&s[start..end]
}
fn pad_to_cells(s: &str, cells: usize) -> String {
let w = s.width();
if w >= cells {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + (cells - w));
out.push_str(s);
out.push_str(&" ".repeat(cells - w));
out
}
fn user_timestamp_padding(
role_prefix_width: usize,
text_width: usize,
timestamp_width: usize,
min_gap: usize,
content_width: usize,
) -> usize {
let total_used = role_prefix_width + text_width + min_gap + timestamp_width;
min_gap + content_width.saturating_sub(total_used)
}
fn line_plain_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn clamp_to_u16(n: usize) -> u16 {
u16::try_from(n).unwrap_or(u16::MAX)
}
fn highlight_line_cells(line: &mut Line<'static>, c0: usize, c1: usize, hl: Style) {
let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 2);
let mut width = 0usize;
for span in line.spans.drain(..) {
let span_w = span.content.width();
let (span_start, span_end) = (width, width + span_w);
width = span_end;
let ov0 = c0.max(span_start);
let ov1 = c1.min(span_end);
if ov1 <= ov0 {
new_spans.push(span); continue;
}
let s = span.content.as_ref();
let b0 = byte_at_cell(s, ov0 - span_start);
let b1 = byte_at_cell(s, ov1 - span_start);
if b0 > 0 {
new_spans.push(Span::styled(s[..b0].to_string(), span.style));
}
new_spans.push(Span::styled(s[b0..b1].to_string(), span.style.patch(hl)));
if b1 < s.len() {
new_spans.push(Span::styled(s[b1..].to_string(), span.style));
}
}
line.spans = new_spans;
}
impl Default for ChatState {
fn default() -> Self {
Self::new()
}
}
pub struct ChatWidget<'a> {
pub messages: &'a [ChatMessage],
pub theme: &'a Theme,
pub wrapped_line_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
pub show_reasoning: bool,
pub blink_on: bool,
}
fn wrap_assistant_content(
content: &str,
content_width: u16,
role_prefix: &str,
role_color: ratatui::style::Color,
theme: &Theme,
) -> Vec<Line<'static>> {
let md_width = (content_width as usize).saturating_sub(2);
let parsed = parse_markdown(content, theme, md_width);
let mut out: Vec<Line<'static>> = Vec::new();
for (line_idx, parsed_line) in parsed.into_iter().enumerate() {
let preformatted = parsed_line.preformatted;
let base_style = parsed_line.line.style;
let continuation = if preformatted {
2
} else {
2 + crate::render::markdown::line_hanging_indent(&parsed_line.line, theme)
};
let mut spans = if line_idx == 0 {
vec![Span::styled(
format!("{} ", role_prefix),
Style::new().fg(role_color).bold(),
)]
} else {
vec![Span::raw(" ")]
};
spans.extend(parsed_line.line.spans);
let new_line = Line::from(spans).style(base_style);
if preformatted {
out.extend(wrap_preformatted(new_line, content_width as usize, 2));
} else {
out.extend(wrap_styled_line(
new_line,
content_width as usize,
continuation,
));
}
}
out
}
struct HashWrite<'a, H: Hasher>(&'a mut H);
impl<H: Hasher> std::fmt::Write for HashWrite<'_, H> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.write(s.as_bytes());
Ok(())
}
}
fn frame_fingerprint(
messages: &[ChatMessage],
theme_seed: u64,
content_width: u16,
show_reasoning: bool,
blink_on: bool,
) -> u64 {
use std::fmt::Write as _;
let mut h = rustc_hash::FxHasher::default();
theme_seed.hash(&mut h);
content_width.hash(&mut h);
show_reasoning.hash(&mut h);
chrono::Local::now().date_naive().hash(&mut h);
if messages.iter().any(|m| {
m.actions
.iter()
.any(|a| matches!(a.result, ActionResult::Running))
}) {
blink_on.hash(&mut h);
}
messages.len().hash(&mut h);
for msg in messages {
msg.content.hash(&mut h);
msg.thinking.hash(&mut h);
msg.timestamp.timestamp().hash(&mut h);
msg.images
.as_ref()
.map_or(0, |imgs| imgs.len())
.hash(&mut h);
let mut hw = HashWrite(&mut h);
let _ = write!(
hw,
"{:?}|{:?}|{:?}|{:?}",
msg.role, msg.kind, msg.metadata, msg.actions
);
}
h.finish()
}
impl<'a> StatefulWidget for ChatWidget<'a> {
type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let code_bg = self.theme.colors.code_background.to_color();
let theme_seed = {
let mut h = rustc_hash::FxHasher::default();
self.theme.colors.foreground.to_color().hash(&mut h);
code_bg.hash(&mut h);
self.theme.colors.header.to_color().hash(&mut h);
h.finish()
};
let content_width = area.width;
let content_area = area;
state.last_chat_area = Some((area.x, area.y, area.width, area.height));
let frame_key = frame_fingerprint(
self.messages,
theme_seed,
content_width,
self.show_reasoning,
self.blink_on,
);
let memo_hit = state
.frame_memo
.as_ref()
.filter(|m| m.key == frame_key)
.map(|m| (m.lines.clone(), m.click_map.clone()));
let mut lines = if let Some((cached_lines, cached_click_map)) = memo_hit {
state.image_click_map = cached_click_map;
cached_lines
} else {
let mut lines: Vec<Line<'static>> = Vec::new();
state.image_click_map.clear();
for (idx, msg) in self.messages.iter().enumerate() {
if matches!(msg.role, MessageRole::Tool) {
continue;
}
if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
if let Some(event_lines) =
render_context_checkpoint_event(msg, self.theme, content_width as usize)
{
lines.extend(event_lines);
lines.push(Line::from(""));
}
continue;
}
if matches!(msg.kind, ChatMessageKind::RunSummary) {
lines.push(Line::from(Span::styled(
format!(" {}", msg.content),
Style::new().fg(self.theme.colors.text_meta.to_color()),
)));
lines.push(Line::from(""));
continue;
}
if matches!(msg.kind, ChatMessageKind::RecoveryNudge) {
continue;
}
if matches!(msg.role, MessageRole::System) {
let meta = Style::new().fg(self.theme.colors.text_meta.to_color());
for wrapped_line in
wrap_text_with_indent(&msg.content, content_width as usize, 2, 2)
{
lines.push(Line::from(Span::styled(wrapped_line, meta)));
}
lines.push(Line::from(""));
continue;
}
let stitch_onto_prev = matches!(msg.kind, ChatMessageKind::Continuation)
&& self.messages[..idx]
.iter()
.rev()
.find(|m| !matches!(m.role, MessageRole::Tool))
.is_some_and(crate::render::mergeable_into);
if stitch_onto_prev && lines.last().is_some_and(|l| line_plain_text(l).is_empty()) {
lines.pop();
}
let (role_prefix, role_color) = match msg.role {
MessageRole::User => (">", self.theme.colors.text_primary.to_color()),
MessageRole::Assistant => ("●", self.theme.colors.text_primary.to_color()),
MessageRole::System | MessageRole::Tool => {
unreachable!("System and Tool messages handled above")
},
};
let role_prefix = if stitch_onto_prev { " " } else { role_prefix };
if matches!(msg.role, MessageRole::Assistant) {
if let Some(ref thinking) = msg.thinking {
let thinking_trimmed = thinking.trim();
if thinking_trimmed.is_empty()
|| thinking_trimmed == "None"
|| thinking_trimmed == "none"
{
} else if self.show_reasoning {
lines.push(Line::from(vec![
Span::styled(
"● ",
Style::new().fg(self.theme.colors.text_disabled.to_color()),
),
Span::styled(
"Thinking...",
Style::new()
.fg(self.theme.colors.text_secondary.to_color())
.italic()
.dim(),
),
]));
let wrapped = wrap_text_with_indent(
thinking,
content_width as usize,
2, 2, );
for wrapped_line in wrapped {
lines.push(Line::from(Span::styled(
wrapped_line,
Style::new()
.fg(self.theme.colors.text_secondary.to_color())
.italic()
.dim(),
)));
}
lines.push(Line::from(""));
} else if msg.content.trim().is_empty() && msg.actions.is_empty() {
continue;
}
}
let mut hasher = rustc_hash::FxHasher::default();
msg.content.hash(&mut hasher);
theme_seed.hash(&mut hasher);
content_width.hash(&mut hasher);
stitch_onto_prev.hash(&mut hasher);
let cache_key = hasher.finish();
let wrapped = if let Some(cached) = self.wrapped_line_cache.get(&cache_key) {
cached.clone()
} else {
let block = wrap_assistant_content(
&msg.content,
content_width,
role_prefix,
role_color,
self.theme,
);
self.wrapped_line_cache.insert(cache_key, block.clone());
if self.wrapped_line_cache.len()
> crate::constants::MARKDOWN_CACHE_MAX_ENTRIES
{
let overflow = self.wrapped_line_cache.len()
- crate::constants::MARKDOWN_CACHE_MAX_ENTRIES;
let stale: Vec<u64> = self
.wrapped_line_cache
.keys()
.copied()
.filter(|&k| k != cache_key)
.take(overflow)
.collect();
for k in stale {
self.wrapped_line_cache.remove(&k);
}
}
block
};
lines.extend(wrapped);
if !msg.actions.is_empty() {
if !msg.content.trim().is_empty() {
lines.push(Line::from(""));
}
render_actions(
&msg.actions,
&mut lines,
self.theme,
content_width as usize,
self.blink_on,
);
}
} else {
let formatted_timestamp = format_relative_timestamp(msg.timestamp);
let timestamp_width = formatted_timestamp.width();
let min_gap = 3;
let cleaned_content = &msg.content;
let role_prefix_width = role_prefix.width() + 1; let first_line_reserved = role_prefix_width + min_gap + timestamp_width;
let wrapped = wrap_text_with_indent(
cleaned_content,
content_width as usize,
first_line_reserved, 2, );
let band_start = lines.len();
for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
if line_idx == 0 {
let text_content = wrapped_line.trim_start(); let text_width = text_content.width();
let mut spans = vec![
Span::styled(
format!("{} ", role_prefix),
Style::new().fg(role_color).bold(),
),
Span::raw(text_content.to_string()),
];
let pad = user_timestamp_padding(
role_prefix_width,
text_width,
timestamp_width,
min_gap,
content_width as usize,
);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(
formatted_timestamp.clone(),
Style::new().fg(self.theme.colors.text_meta.to_color()),
));
lines.push(Line::from(spans));
} else {
lines.push(Line::from(wrapped_line.clone()));
}
}
if matches!(msg.role, MessageRole::User) {
let user_bg = self.theme.colors.user_message_background.to_color();
let cw = content_width as usize;
for line in &mut lines[band_start..] {
let used: usize = line.spans.iter().map(|s| s.content.width()).sum();
if used < cw {
line.spans.push(Span::raw(" ".repeat(cw - used)));
}
line.style = line.style.bg(user_bg);
}
}
}
if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
&& let Some(ref images) = msg.images
&& !images.is_empty()
{
for (i, _) in images.iter().enumerate() {
let content_line = lines.len();
let image_number =
msg.image_numbers.as_ref().and_then(|v| v.get(i)).copied();
state.image_click_map.push((
clamp_to_u16(content_line),
ImageClickTarget {
message_index: idx,
image_index: i,
image_number,
},
));
let label = image_number
.map(|n| format!("[Image #{n}]"))
.unwrap_or_else(|| format!("[Image #{}]", i + 1));
lines.push(Line::from(vec![
Span::styled(
" ⎿ ",
Style::new().fg(self.theme.colors.info.to_color()),
),
Span::styled(
label,
Style::new().fg(self.theme.colors.info.to_color()).italic(),
),
]));
}
}
lines.push(Line::from(""));
}
state.last_rendered_rows = lines.iter().map(line_plain_text).collect();
state.frame_memo = Some(FrameMemo {
key: frame_key,
lines: lines.clone(),
click_map: state.image_click_map.clone(),
});
lines
};
if let Some((a, b)) = state.selection
&& !lines.is_empty()
{
let (start, end) = if a <= b { (a, b) } else { (b, a) };
let sel_style = Style::new().add_modifier(Modifier::REVERSED);
let last_line = lines.len() - 1;
for (line_idx, line) in lines
.iter_mut()
.enumerate()
.take(end.0.min(last_line) + 1)
.skip(start.0)
{
let c0 = if line_idx == start.0 { start.1 } else { 0 };
let c1 = if line_idx == end.0 { end.1 } else { usize::MAX };
if c1 > c0 {
highlight_line_cells(line, c0, c1, sel_style);
}
}
}
let content_height = lines.len();
let viewport_height = area.height;
let scroll_pos = state.get_scroll_position(clamp_to_u16(content_height), viewport_height);
state.last_scroll_position = scroll_pos;
let paragraph = Paragraph::new(lines)
.block(Block::default())
.scroll((scroll_pos, 0));
paragraph.render(content_area, buf);
}
}
fn render_context_checkpoint_event(
msg: &ChatMessage,
theme: &Theme,
viewport_width: usize,
) -> Option<Vec<Line<'static>>> {
if !matches!(msg.role, MessageRole::User) {
return None;
}
let metadata = msg.metadata.as_ref();
let trigger = metadata
.and_then(|value| value.get("trigger"))
.and_then(|value| value.as_str())
.unwrap_or("manual");
let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
let archived_messages =
metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
let preserved_messages =
metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
let duration_secs = metadata
.and_then(|value| value.get("duration_secs"))
.and_then(|value| value.as_f64());
let review_status = metadata
.and_then(|value| value.get("review_status"))
.and_then(|value| value.as_str());
let review_error = metadata
.and_then(|value| value.get("review_error"))
.and_then(|value| value.as_str());
let action_color = theme.colors.info.to_color();
let mut result = match (before_tokens, after_tokens) {
(Some(before), Some(after)) => {
format!(
"{} -> {} tokens",
format_compact_count(before),
format_compact_count(after)
)
},
_ => "Context compacted".to_string(),
};
if let Some(count) = archived_messages {
result.push_str(&format!(
", archived {} {}",
count,
if count == 1 { "message" } else { "messages" }
));
}
if let Some(count) = preserved_messages {
result.push_str(&format!(
", preserved {} {}",
count,
if count == 1 { "message" } else { "messages" }
));
}
if let Some(status) = review_status {
match status {
"reviewed" => result.push_str(", reviewed"),
"draft_validated" => result.push_str(", validated draft"),
_ => {},
}
}
result = append_action_duration(result, duration_secs);
let mut lines = vec![Line::from(vec![
Span::styled("● ", Style::new().fg(action_color).bold()),
Span::styled("Compact(", Style::new().fg(action_color).bold()),
Span::styled(
trigger.to_string(),
Style::new().fg(theme.colors.text_secondary.to_color()),
),
Span::styled(")", Style::new().fg(action_color).bold()),
])];
lines.extend(wrap_styled_line(
Line::from(vec![
Span::styled(" ⎿ ", Style::new().fg(action_color)),
Span::styled(
result,
Style::new().fg(theme.colors.text_secondary.to_color()),
),
]),
viewport_width,
4,
));
if let Some(error) = review_error.filter(|error| !error.trim().is_empty()) {
lines.extend(wrap_styled_line(
Line::from(vec![
Span::styled(" ", Style::new().fg(action_color)),
Span::styled(
format!("review: {}", compact_inline_error(error, 180)),
Style::new().fg(theme.colors.warning.to_color()),
),
]),
viewport_width,
4,
));
}
Some(lines)
}
fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
value
.get(key)?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
}
fn compact_inline_error(text: &str, max_chars: usize) -> String {
let text = text.trim();
if text.chars().count() <= max_chars {
return text.to_string();
}
let keep = max_chars.saturating_sub(3);
let mut out: String = text.chars().take(keep).collect();
out.push_str("...");
out
}
fn expand_tabs(s: &str) -> String {
const TAB_WIDTH: usize = 4;
if !s.contains('\t') {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + TAB_WIDTH);
let mut col = 0usize;
for ch in s.chars() {
if ch == '\t' {
let n = TAB_WIDTH - (col % TAB_WIDTH);
for _ in 0..n {
out.push(' ');
}
col += n;
} else {
out.push(ch);
col += UnicodeWidthChar::width(ch).unwrap_or(0);
}
}
out
}
fn render_actions(
actions: &[ActionDisplay],
lines: &mut Vec<Line>,
theme: &Theme,
viewport_width: usize,
blink_on: bool,
) {
for (action_idx, action) in actions.iter().enumerate() {
if action_idx > 0 {
lines.push(Line::from(""));
}
if let Some(meta) = &action.metadata
&& let ToolMetadata::Questions {
answers,
remembered,
} = &meta.detail
&& matches!(action.result, ActionResult::Success { .. })
{
render_question_answers(answers, *remembered, lines, theme, viewport_width);
continue;
}
if let Some(meta) = &action.metadata
&& let ToolMetadata::Plan { path, body, .. } = &meta.detail
&& matches!(action.result, ActionResult::Success { .. })
{
render_plan_approved(path, body, lines, theme, viewport_width);
continue;
}
let action_color = match action.action_type.as_str() {
"Write" | "Update" => theme.colors.success.to_color(),
"Delete" => theme.colors.warning.to_color(),
_ => theme.colors.info.to_color(),
};
let dot_style = if matches!(action.result, ActionResult::Running) && !blink_on {
Style::new()
.fg(theme.colors.text_disabled.to_color())
.bold()
} else {
Style::new().fg(action_color).bold()
};
push_action_header(
lines,
action,
action_color,
dot_style,
theme,
viewport_width,
);
match &action.result {
ActionResult::Running => {},
ActionResult::Success { .. } => {
let result_msg = match &action.details {
ActionDetails::FileContent { line_count, .. } => {
let base = format!(
"{} {} written",
line_count,
if *line_count == 1 { "line" } else { "lines" }
);
append_action_duration(base, action.duration_seconds)
},
ActionDetails::Diff { summary, .. } => summary.clone(),
ActionDetails::Preview { text, .. } => text.clone(),
ActionDetails::Simple => {
append_action_duration(String::new(), action.duration_seconds)
},
};
for (idx, line) in result_msg.lines().enumerate() {
let prefix = if idx == 0 { " ⎿ " } else { " " };
lines.extend(wrap_styled_line(
Line::from(vec![
Span::styled(prefix, Style::new().fg(action_color)),
Span::styled(
line.to_string(),
Style::new().fg(theme.colors.text_secondary.to_color()),
),
]),
viewport_width,
4,
));
}
if let ActionDetails::FileContent {
content,
line_count,
} = &action.details
{
let preview_lines: Vec<&str> = content.lines().take(10).collect();
if !preview_lines.is_empty() {
lines.push(Line::from(vec![Span::styled(
" ",
Style::new().fg(action_color),
)]));
let preview_content = preview_lines.join("\n");
let mut parsed = parse_markdown(
&format!("```\n{}\n```", preview_content),
theme,
viewport_width.saturating_sub(4),
);
for parsed_line in parsed.iter_mut() {
let mut new_spans =
vec![Span::styled(" ", Style::new().fg(action_color))];
new_spans.append(&mut parsed_line.line.spans);
parsed_line.line.spans = new_spans;
}
lines.extend(
parsed
.into_iter()
.flat_map(|ml| wrap_preformatted(ml.line, viewport_width, 6)),
);
if *line_count > 10 {
lines.push(Line::from(vec![
Span::styled(" ", Style::new().fg(action_color)),
Span::styled(
format!("... ({} more lines)", line_count - 10),
Style::new()
.fg(theme.colors.text_disabled.to_color())
.italic(),
),
]));
}
}
}
if let ActionDetails::Diff { diff, .. } = &action.details {
let diff_lines: Vec<&str> = diff.lines().collect();
let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
if !display_lines.is_empty() {
let removed_bg = theme.colors.diff_removed_bg.to_color();
let added_bg = theme.colors.diff_added_bg.to_color();
for diff_line in &display_lines {
let diff_line = expand_tabs(diff_line);
match parse_diff_line(&diff_line) {
DiffLineKind::Removed => {
push_wrapped_diff_rows(
lines,
format!(" {}", diff_line),
Style::new()
.fg(theme.colors.error.to_color())
.bg(removed_bg),
viewport_width,
);
},
DiffLineKind::Added => {
push_wrapped_diff_rows(
lines,
format!(" {}", diff_line),
Style::new()
.fg(theme.colors.success.to_color())
.bg(added_bg),
viewport_width,
);
},
DiffLineKind::Context => {
lines.extend(wrap_preformatted(
Line::from(vec![
Span::styled(" ", Style::new().fg(action_color)),
Span::styled(
diff_line,
Style::new()
.fg(theme.colors.text_secondary.to_color()),
),
]),
viewport_width,
6,
));
},
}
}
let remaining = diff_lines.len().saturating_sub(display_lines.len());
if remaining > 0 {
lines.push(Line::from(vec![
Span::styled(" ", Style::new().fg(action_color)),
Span::styled(
format!("... ({} more lines)", remaining),
Style::new()
.fg(theme.colors.text_disabled.to_color())
.italic(),
),
]));
}
}
}
},
ActionResult::Error { error } => {
let error =
append_action_duration(format!("Error: {}", error), action.duration_seconds);
for (idx, err_line) in error.lines().enumerate() {
let prefix = if idx == 0 { " ⎿ " } else { " " };
lines.extend(wrap_styled_line(
Line::from(vec![
Span::styled(prefix, Style::new().fg(theme.colors.error.to_color())),
Span::styled(
err_line.to_string(),
Style::new().fg(theme.colors.error.to_color()),
),
]),
viewport_width,
4,
));
}
},
}
}
}
fn render_plan_approved(
path: &str,
body: &str,
lines: &mut Vec<Line>,
theme: &Theme,
viewport_width: usize,
) {
lines.push(Line::from(Span::styled(
format!("● User approved the plan — {path}"),
Style::new().fg(theme.colors.success.to_color()),
)));
let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
let parsed = parse_markdown(body, theme, viewport_width.saturating_sub(4));
let mut first_row = true;
for mut parsed_line in parsed {
let gutter = if first_row { " ⎿ " } else { " " };
first_row = false;
let mut spans = vec![Span::styled(gutter, gutter_style)];
spans.append(&mut parsed_line.line.spans);
lines.push(Line::from(spans));
}
}
fn render_question_answers(
answers: &[QuestionAnswer],
remembered: bool,
lines: &mut Vec<Line>,
theme: &Theme,
viewport_width: usize,
) {
let header = if remembered {
"User answered the model's questions (remembered):"
} else {
"User answered the model's questions:"
};
lines.push(Line::from(Span::styled(
format!("● {header}"),
Style::new().fg(theme.colors.text_primary.to_color()),
)));
let gutter_style = Style::new().fg(theme.colors.text_secondary.to_color());
let text_style = Style::new().fg(theme.colors.text_secondary.to_color());
let note_style = Style::new()
.fg(theme.colors.text_disabled.to_color())
.italic();
let wrap_width = viewport_width.saturating_sub(4);
let mut first_row = true;
for answer in answers {
let value = if answer.selected.is_empty() {
"(no selection)".to_string()
} else {
answer.selected.join(", ")
};
let entry = format!("· {} → {}", answer.question, value);
let mut rows: Vec<(String, Style)> = wrap_text_with_indent(&entry, wrap_width, 0, 2)
.into_iter()
.map(|row| (row, text_style))
.collect();
if let Some(note) = &answer.note {
rows.extend(
wrap_text_with_indent(&format!("(note: {note})"), wrap_width, 2, 4)
.into_iter()
.map(|row| (row, note_style)),
);
}
for (row, style) in rows {
let gutter = if first_row { " ⎿ " } else { " " };
first_row = false;
lines.push(Line::from(vec![
Span::styled(gutter, gutter_style),
Span::styled(row, style),
]));
}
}
}
const MAX_ACTION_HEADER_ROWS: usize = 4;
fn push_action_header(
lines: &mut Vec<Line>,
action: &ActionDisplay,
action_color: Color,
dot_style: Style,
theme: &Theme,
viewport_width: usize,
) {
let bold = Style::new().fg(action_color).bold();
let secondary = Style::new().fg(theme.colors.text_secondary.to_color());
if action.target.is_empty() {
lines.push(Line::from(vec![
Span::styled("● ", dot_style),
Span::styled(format!("{}()", action.action_type), bold),
]));
return;
}
let open = format!("{}(", action.action_type);
let first_indent = 2 + open.width();
let wrap_width = viewport_width.saturating_sub(2).max(first_indent + 1);
let mut rows = wrap_text_with_indent(&action.target, wrap_width, first_indent, 4);
let truncated = rows.len() > MAX_ACTION_HEADER_ROWS;
rows.truncate(MAX_ACTION_HEADER_ROWS);
let last = rows.len().saturating_sub(1);
for (i, row) in rows.into_iter().enumerate() {
let mut spans = if i == 0 {
vec![
Span::styled("● ", dot_style),
Span::styled(open.clone(), bold),
Span::styled(row.trim_start().to_string(), secondary),
]
} else {
vec![Span::styled(row, secondary)]
};
if i == last {
if truncated {
spans.push(Span::styled(
"…",
Style::new().fg(theme.colors.text_disabled.to_color()),
));
}
spans.push(Span::styled(")", bold));
}
lines.push(Line::from(spans));
}
}
fn push_wrapped_diff_rows(lines: &mut Vec<Line>, text: String, style: Style, width: usize) {
for row in wrap_preformatted(Line::from(Span::raw(text)), width, 6) {
let padded = pad_to_cells(&line_plain_text(&row), width);
lines.push(Line::from(Span::styled(padded, style)));
}
}
fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
if let Some(seconds) = duration_seconds {
if !text.is_empty() {
text.push_str(", ");
}
text.push_str("took ");
text.push_str(&format_action_duration(seconds));
}
text
}
fn format_action_duration(seconds: f64) -> String {
if seconds < 1.0 {
format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
} else if seconds < 10.0 {
format!("{:.1}s", seconds)
} else {
format!("{}s", seconds.round() as u64)
}
}
fn hard_break_plain_token(
token: &str,
out: &mut Vec<String>,
current_line: &mut String,
current_length: &mut usize,
width: usize,
continuation_indent: usize,
initial_budget: usize,
) {
let cont_budget = width.saturating_sub(continuation_indent).max(1);
let mut line_budget = initial_budget.max(1);
if *current_length > 0 {
out.push(std::mem::take(current_line));
current_line.push_str(&" ".repeat(continuation_indent));
*current_length = 0;
line_budget = cont_budget;
}
for ch in token.chars() {
let cw = ch.width().unwrap_or(0);
if *current_length + cw > line_budget && *current_length > 0 {
out.push(std::mem::take(current_line));
current_line.push_str(&" ".repeat(continuation_indent));
*current_length = 0;
line_budget = cont_budget;
}
current_line.push(ch);
*current_length += cw;
}
}
fn wrap_text_with_indent(
text: &str,
width: usize,
first_line_indent: usize,
continuation_indent: usize,
) -> Vec<String> {
let mut wrapped_lines = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
if line.is_empty() {
wrapped_lines.push(String::new());
continue;
}
let current_indent = if line_idx == 0 {
first_line_indent
} else {
continuation_indent
};
let available_width = width.saturating_sub(current_indent);
if available_width == 0 {
wrapped_lines.push(" ".repeat(current_indent));
continue;
}
let words: Vec<&str> = line.split_whitespace().collect();
if words.is_empty() {
wrapped_lines.push(" ".repeat(current_indent));
continue;
}
let mut current_line = String::with_capacity(width);
current_line.push_str(&" ".repeat(current_indent));
let mut current_length = 0;
for (word_idx, word) in words.iter().enumerate() {
let word_width = word.width();
if word_idx == 0 {
if word_width <= available_width {
current_line.push_str(word);
current_length = word_width;
} else {
hard_break_plain_token(
word,
&mut wrapped_lines,
&mut current_line,
&mut current_length,
width,
continuation_indent,
available_width,
);
}
} else if current_length + 1 + word_width <= available_width {
current_line.push(' ');
current_line.push_str(word);
current_length += 1 + word_width;
} else if word_width <= available_width {
wrapped_lines.push(current_line);
current_line = String::with_capacity(width);
current_line.push_str(&" ".repeat(continuation_indent));
current_line.push_str(word);
current_length = word_width;
} else {
hard_break_plain_token(
word,
&mut wrapped_lines,
&mut current_line,
&mut current_length,
width,
continuation_indent,
available_width,
);
}
}
if !current_line.trim().is_empty() {
wrapped_lines.push(current_line);
}
}
wrapped_lines
}
#[allow(clippy::too_many_arguments)]
fn hard_break_styled_word(
fragments: &[(String, Style)],
result_lines: &mut Vec<Line<'static>>,
current_line_spans: &mut Vec<Span<'static>>,
current_line_width: &mut usize,
continuation_indent: usize,
continuation_capacity: usize,
mut line_capacity: usize,
) {
for (text, style) in fragments {
let mut buf = String::new();
for ch in text.chars() {
let cw = ch.width().unwrap_or(0);
if *current_line_width + cw > line_capacity && *current_line_width > 0 {
if !buf.is_empty() {
current_line_spans.push(Span::styled(std::mem::take(&mut buf), *style));
}
result_lines.push(Line::from(std::mem::take(current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
*current_line_width = 0;
line_capacity = continuation_capacity.max(1);
}
buf.push(ch);
*current_line_width += cw;
}
if !buf.is_empty() {
current_line_spans.push(Span::styled(buf, *style));
}
}
}
fn wrap_styled_line(
line: Line<'static>,
width: usize,
continuation_indent: usize,
) -> Vec<Line<'static>> {
let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total_width <= width {
return vec![line];
}
let mut result_lines = Vec::new();
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
let mut current_line_width = 0usize;
let available_width = width.saturating_sub(continuation_indent);
let leading_indent: usize = {
let mut n = 0;
for span in &line.spans {
let spaces = span.content.len() - span.content.trim_start_matches(' ').len();
n += spaces;
if spaces < span.content.len() {
break; }
}
n
};
let mut words: Vec<Vec<(String, Style)>> = Vec::new();
let mut current_word: Vec<(String, Style)> = Vec::new();
for span in &line.spans {
let mut frag = String::new();
for ch in span.content.chars() {
if ch.is_whitespace() {
if !frag.is_empty() {
current_word.push((std::mem::take(&mut frag), span.style));
}
if !current_word.is_empty() {
words.push(std::mem::take(&mut current_word));
}
} else {
frag.push(ch);
}
}
if !frag.is_empty() {
current_word.push((frag, span.style));
}
}
if !current_word.is_empty() {
words.push(current_word);
}
fn emit_word(spans: &mut Vec<Span<'static>>, word: Vec<(String, Style)>) {
for (text, style) in word {
spans.push(Span::styled(text, style));
}
}
for word in words {
let word_width: usize = word.iter().map(|(text, _)| text.width()).sum();
if current_line_width == 0 && result_lines.is_empty() {
if leading_indent > 0 {
current_line_spans.push(Span::raw(" ".repeat(leading_indent)));
current_line_width += leading_indent;
}
if word_width <= available_width {
current_line_width += word_width;
emit_word(&mut current_line_spans, word);
} else {
hard_break_styled_word(
&word,
&mut result_lines,
&mut current_line_spans,
&mut current_line_width,
continuation_indent,
available_width,
width,
);
}
continue;
}
let sep = usize::from(current_line_width > 0);
if current_line_width + sep + word_width <= available_width {
if sep == 1 {
current_line_spans.push(Span::raw(" "));
}
current_line_width += sep + word_width;
emit_word(&mut current_line_spans, word);
} else if word_width <= available_width {
result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
current_line_width = word_width;
emit_word(&mut current_line_spans, word);
} else {
result_lines.push(Line::from(std::mem::take(&mut current_line_spans)));
current_line_spans.push(Span::raw(" ".repeat(continuation_indent)));
current_line_width = 0;
hard_break_styled_word(
&word,
&mut result_lines,
&mut current_line_spans,
&mut current_line_width,
continuation_indent,
available_width,
available_width,
);
}
}
if !current_line_spans.is_empty() {
result_lines.push(Line::from(current_line_spans));
}
if result_lines.is_empty() {
vec![line]
} else {
result_lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn question_answers_render_as_question_arrow_answer_block() {
use crate::domain::{QuestionAnswer, ToolMetadata, ToolRunMetadata};
let theme = Theme::dark();
let answers = vec![
QuestionAnswer {
header: "Snack".to_string(),
question: "Which snack fuels your next coding session?".to_string(),
selected: vec!["Coffee (Recommended)".to_string()],
note: None,
},
QuestionAnswer {
header: "Powers".to_string(),
question: "Which superpowers would you take?".to_string(),
selected: vec![
"Read any codebase instantly".to_string(),
"Bugs reproduce on demand".to_string(),
],
note: Some("only on weekdays".to_string()),
},
];
let action = ActionDisplay {
action_type: "ask_user_question".to_string(),
target: String::new(),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Simple,
duration_seconds: Some(93.0),
metadata: Some(ToolRunMetadata {
detail: ToolMetadata::Questions {
answers,
remembered: false,
},
..Default::default()
}),
};
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, 120, true);
let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
let all = rows.join("\n");
assert_eq!(rows[0], "● User answered the model's questions:");
assert!(
rows[1].starts_with(" ⎿ · Which snack fuels your next coding session? → Coffee"),
"got {:?}",
rows[1]
);
assert!(
all.contains(
"· Which superpowers would you take? → Read any codebase instantly, \
Bugs reproduce on demand"
),
"got {all}"
);
assert!(all.contains("(note: only on weekdays)"), "got {all}");
assert!(!all.contains("ask_user_question("), "got {all}");
assert!(!all.contains("took"), "got {all}");
}
#[test]
fn diff_background_fills_full_width_with_tabs() {
use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::dark();
let added_bg = theme.colors.diff_added_bg.to_color();
let removed_bg = theme.colors.diff_removed_bg.to_color();
let diff = format!(
" 62{m}\tconst out = [];\n 63{p}\t\tlet fixed = false;\n 64{p}\t\t\tdeeplyNested();",
m = DIFF_REMOVED_MARKER,
p = DIFF_ADDED_MARKER
);
let action = ActionDisplay {
action_type: "Update".to_string(),
target: "engine.ts".to_string(),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Diff {
summary: "ok".to_string(),
diff,
},
duration_seconds: Some(0.3),
metadata: None,
};
let width: u16 = 60;
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, width as usize, true);
let h = lines.len() as u16;
let backend = TestBackend::new(width, h);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| {
Paragraph::new(lines).render(Rect::new(0, 0, width, h), f.buffer_mut());
})
.unwrap();
let buf = term.backend().buffer();
for y in 0..h {
let is_diff_row = (0..width).any(|x| {
let bg = buf[(x, y)].bg;
bg == added_bg || bg == removed_bg
});
if !is_diff_row {
continue;
}
for x in 0..width {
let bg = buf[(x, y)].bg;
assert!(
bg == added_bg || bg == removed_bg,
"diff background must fill the whole row, but column {x} of row {y} is unfilled (staircase)"
);
}
}
}
fn assert_rows_fit(lines: &[Line], width: usize) {
for (i, line) in lines.iter().enumerate() {
let w: usize = line.spans.iter().map(|s| s.content.width()).sum();
assert!(
w <= width,
"row {i} is {w} cells wide, exceeding the {width}-cell viewport: {:?}",
line_plain_text(line)
);
}
}
#[test]
fn action_header_and_error_wrap_instead_of_clipping() {
let theme = Theme::dark();
let action = ActionDisplay {
action_type: "Error".to_string(),
target: "Backend error".to_string(),
result: ActionResult::Error {
error: r#"HTTP error 404: {"error":{"code":"model_not_found","message":"The requested model was not found.","param":null,"type":"invalid_request_error"}}"#.to_string(),
},
details: ActionDetails::Simple,
duration_seconds: None,
metadata: None,
};
let width = 60usize;
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, width, true);
assert_rows_fit(&lines, width);
let rendered = lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("invalid_request_error"));
assert!(
lines.len() > 2,
"a 140-cell error at width 60 must span multiple rows"
);
}
#[test]
fn action_header_wraps_long_command_and_keeps_closing_paren() {
let theme = Theme::dark();
let action = ActionDisplay {
action_type: "Bash".to_string(),
target: "python3 -c 'print(1)' && echo a-very-long-command-line \
that keeps going well past the sixty cell viewport edge"
.to_string(),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Simple,
duration_seconds: Some(0.1),
metadata: None,
};
let width = 60usize;
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, width, true);
assert_rows_fit(&lines, width);
let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
assert!(rows[0].starts_with("● Bash("));
assert!(
rows.len() >= 2,
"the long command must wrap the header across rows"
);
let last_target_row = rows
.iter()
.rfind(|r| r.trim_end().ends_with(')'))
.expect("wrapped header must still close its paren");
assert!(last_target_row.trim_end().ends_with(')'));
}
#[test]
fn action_header_caps_rows_and_marks_truncation() {
let theme = Theme::dark();
let action = ActionDisplay {
action_type: "Bash".to_string(),
target: "word ".repeat(400),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Simple,
duration_seconds: None,
metadata: None,
};
let width = 60usize;
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, width, true);
assert_rows_fit(&lines, width);
let header_rows: Vec<String> = lines
.iter()
.map(line_plain_text)
.take_while(|r| !r.trim_start().starts_with('⎿'))
.collect();
assert_eq!(
header_rows.len(),
MAX_ACTION_HEADER_ROWS,
"header must cap at MAX_ACTION_HEADER_ROWS rows"
);
assert!(
header_rows.last().unwrap().trim_end().ends_with("…)"),
"capped header must end with …) — got {:?}",
header_rows.last().unwrap()
);
}
#[test]
fn action_header_preserves_multiline_command_rows() {
let theme = Theme::dark();
let action = ActionDisplay {
action_type: "Bash".to_string(),
target: "python3 - << 'PY'\nfrom PIL import Image\nPY".to_string(),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Simple,
duration_seconds: None,
metadata: None,
};
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, 80, true);
let rows: Vec<String> = lines.iter().map(line_plain_text).collect();
assert!(rows[0].contains("python3 - << 'PY'"));
assert!(rows[1].contains("from PIL import Image"));
assert!(!rows[0].contains("'PY'from"), "newline must not be dropped");
}
#[test]
fn action_result_summary_wraps_instead_of_clipping() {
let theme = Theme::dark();
let action = ActionDisplay {
action_type: "Tasks".to_string(),
target: "update 3 steps".to_string(),
result: ActionResult::Success {
output: String::new(),
images: None,
},
details: ActionDetails::Preview {
text: "Tasks 5/6 · User chose SKIP for domain/phone/address - \
placeholders kept intentionally until real data available. \
Task 2 and 6 deferred., to revisit later"
.to_string(),
line_count: None,
},
duration_seconds: None,
metadata: None,
};
let width = 60usize;
let mut lines: Vec<Line> = Vec::new();
render_actions(&[action], &mut lines, &theme, width, true);
assert_rows_fit(&lines, width);
let rendered = lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("revisit later"),
"the summary's tail must survive the wrap instead of being clipped"
);
}
#[test]
fn wrapped_line_cache_hit_matches_cache_miss() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::dark();
let messages = vec![
ChatMessage::assistant(
"# Heading\n\nSome **bold** prose long enough that it has to wrap \
across this narrow viewport more than once.\n\n\
- a list item that also keeps going past the edge so it wraps too\n\
- second item\n\n```rust\nfn a_very_long_preformatted_code_line_that_overflows() {}\n```",
),
ChatMessage::assistant("Short follow-up paragraph."),
];
let (width, height): (u16, u16) = (40, 40);
let render_once = |cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut state = ChatState::new();
term.draw(|f| {
let widget = ChatWidget {
messages: &messages,
theme: &theme,
wrapped_line_cache: cache,
show_reasoning: true,
blink_on: true,
};
f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
})
.unwrap();
term.backend().buffer().clone()
};
let mut shared = FxHashMap::default();
let miss = render_once(&mut shared);
assert!(!shared.is_empty(), "first render must populate the cache");
let hit = render_once(&mut shared);
assert_eq!(miss, hit, "cache hit must render identically to cache miss");
let mut cold_cache = FxHashMap::default();
let cold = render_once(&mut cold_cache);
assert_eq!(hit, cold, "warm-cache frame must equal a cold-cache frame");
}
#[test]
fn system_notice_renders_as_dim_meta_text_without_bullet_or_timestamp() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::dark();
let messages = vec![ChatMessage::system(
"Heads up: this model reports no vision capability",
)];
let (width, height): (u16, u16) = (60, 10);
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut state = ChatState::new();
let mut cache = FxHashMap::default();
term.draw(|f| {
let widget = ChatWidget {
messages: &messages,
theme: &theme,
wrapped_line_cache: &mut cache,
show_reasoning: true,
blink_on: true,
};
f.render_stateful_widget(widget, Rect::new(0, 0, width, height), &mut state);
})
.unwrap();
let buf = term.backend().buffer();
let rows: Vec<String> = (0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect();
let all = rows.join("\n");
assert!(
!all.contains('●'),
"no role bullet on system notices: {all}"
);
assert!(
!all.contains("Today at"),
"no timestamp on system notices: {all}"
);
let row = rows
.iter()
.position(|r| r.contains("Heads up"))
.expect("notice rendered");
assert!(
rows[row].starts_with(" Heads up"),
"2-space indent, nothing in the gutter: {:?}",
rows[row]
);
let col = rows[row].find("Heads up").unwrap(); assert_eq!(
buf[(col as u16, row as u16)].fg,
theme.colors.text_meta.to_color(),
"notice text uses the muted meta gray"
);
}
#[test]
fn byte_at_cell_clamps_and_respects_cjk() {
assert_eq!(byte_at_cell("hello", 0), 0);
assert_eq!(byte_at_cell("hello", 3), 3);
assert_eq!(byte_at_cell("hello", 99), 5); assert_eq!(byte_at_cell("你好", 0), 0);
assert_eq!(byte_at_cell("你好", 2), 3); assert_eq!(byte_at_cell("你好", 1), 3);
}
#[test]
fn slice_by_cells_extracts_display_range() {
assert_eq!(slice_by_cells("hello world", 0, 5), "hello");
assert_eq!(slice_by_cells("hello world", 6, 11), "world");
assert_eq!(slice_by_cells("你好world", 2, 7), "好wor");
}
#[test]
fn pad_to_cells_fills_to_display_width() {
assert_eq!(pad_to_cells("ab", 5), "ab ");
assert_eq!(pad_to_cells("你好", 6), "你好 ");
assert_eq!(pad_to_cells("你好", 3), "你好");
assert_eq!(pad_to_cells("", 0), "");
}
#[test]
fn user_timestamp_padding_aligns_on_display_cells() {
assert_eq!(user_timestamp_padding(4, 5, 8, 3, 40), 23);
let pad = user_timestamp_padding(4, 10, 8, 3, 40);
assert_eq!(4 + 10 + pad + 8, 40);
assert_eq!(user_timestamp_padding(4, 100, 8, 3, 40), 3);
}
#[test]
fn wrap_preformatted_hard_wraps_preserving_spaces() {
let line = Line::from(vec![Span::raw(" aaaa bbbb cccc")]);
let wrapped = wrap_preformatted(line, 10, 2);
assert!(wrapped.len() >= 2, "wide line should wrap to multiple rows");
let first: String = wrapped[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
first.starts_with(" aaaa"),
"indentation must be preserved, got {first:?}"
);
let second: String = wrapped[1]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
second.starts_with(" "),
"continuation should get the hanging indent, got {second:?}"
);
}
#[test]
fn wrap_preformatted_short_line_unchanged() {
let line = Line::from(vec![Span::raw(" short")]);
let wrapped = wrap_preformatted(line, 40, 2);
assert_eq!(wrapped.len(), 1);
let text: String = wrapped[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text, " short");
}
fn state_with_rows(rows: &[&str], sel: ((usize, usize), (usize, usize))) -> ChatState {
let mut st = ChatState::new();
st.last_rendered_rows = rows.iter().map(|r| r.to_string()).collect();
st.selection = Some(sel);
st
}
#[test]
fn selected_text_single_line() {
let st = state_with_rows(&["> hello world"], ((0, 2), (0, 7)));
assert_eq!(st.selected_text().as_deref(), Some("hello"));
}
#[test]
fn selected_text_spans_multiple_rows() {
let st = state_with_rows(&["> first line", " second line"], ((0, 2), (1, 8)));
assert_eq!(st.selected_text().as_deref(), Some("first line\nsecond"));
}
#[test]
fn selected_text_strips_margin_but_keeps_code_indentation() {
let st = state_with_rows(
&[" fn main() {", " let x = 1;", " }"],
((0, 0), (2, 3)),
);
assert_eq!(
st.selected_text().as_deref(),
Some("fn main() {\n let x = 1;\n}")
);
}
#[test]
fn selected_text_normalizes_reversed_drag() {
let st = state_with_rows(&["> hello world"], ((0, 7), (0, 2)));
assert_eq!(st.selected_text().as_deref(), Some("hello"));
}
#[test]
fn selected_text_empty_selection_is_none() {
let st = state_with_rows(&["> hello"], ((0, 3), (0, 3)));
assert_eq!(st.selected_text(), None);
}
#[test]
fn highlight_line_cells_splits_spans_on_selection() {
let mut line = Line::from(vec![Span::raw("abcdef")]);
highlight_line_cells(
&mut line,
2,
4,
Style::new().add_modifier(Modifier::REVERSED),
);
let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
assert_eq!(texts, vec!["ab", "cd", "ef"]);
assert!(
line.spans[1]
.style
.add_modifier
.contains(Modifier::REVERSED)
);
assert!(
!line.spans[0]
.style
.add_modifier
.contains(Modifier::REVERSED)
);
}
#[test]
fn context_checkpoint_renders_as_compact_event() {
let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
msg.kind = ChatMessageKind::ContextCheckpoint;
msg.metadata = Some(serde_json::json!({
"trigger": "manual",
"before_tokens": 43_800,
"after_tokens": 9_200,
"archived_message_count": 18,
"preserved_message_count": 4,
"duration_secs": 2.4,
"review_status": "reviewed",
}));
let lines =
render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
let rendered = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Compact(manual)"));
assert!(rendered.contains("43.8k -> 9.2k tokens"));
assert!(rendered.contains("archived 18 messages"));
assert!(rendered.contains("preserved 4 messages"));
assert!(rendered.contains("reviewed"));
assert!(!rendered.contains("full checkpoint summary"));
}
#[test]
fn context_checkpoint_renders_validated_draft() {
let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
msg.kind = ChatMessageKind::ContextCheckpoint;
msg.metadata = Some(serde_json::json!({
"trigger": "auto_threshold",
"before_tokens": 43_800,
"after_tokens": 9_200,
"archived_message_count": 18,
"preserved_message_count": 4,
"duration_secs": 2.4,
"review_status": "draft_validated",
"review_error": "provider overloaded",
}));
let lines =
render_context_checkpoint_event(&msg, &Theme::dark(), 120).expect("event lines");
let rendered = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Compact(auto_threshold)"));
assert!(rendered.contains("validated draft"));
assert!(rendered.contains("review: provider overloaded"));
}
#[test]
fn wrap_styled_line_uses_display_width_for_cjk() {
let line = Line::from(Span::raw("你好世界".to_string()));
let wrapped = wrap_styled_line(line, 10, 2);
assert_eq!(
wrapped.len(),
1,
"CJK input fitting in display-width should NOT be wrapped; got {} lines",
wrapped.len()
);
}
#[test]
fn wrap_styled_line_ascii_wraps_when_too_long() {
let line = Line::from(Span::raw(
"the quick brown fox jumps over the lazy dog".to_string(),
));
let wrapped = wrap_styled_line(line, 15, 2);
assert!(
wrapped.len() >= 2,
"long ASCII input should wrap to multiple lines; got {}",
wrapped.len()
);
}
fn first_segment_text(wrapped: &[Line<'static>]) -> String {
wrapped[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
#[test]
fn wrap_styled_line_keeps_gutter_on_wrapped_paragraph() {
let line = Line::from(vec![
Span::raw(" "), Span::raw(
"No source files, no config, no docs, no build system and more words to wrap"
.to_string(),
),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "should wrap");
let first = first_segment_text(&wrapped);
assert!(
first.starts_with(" ") && first.trim_start().starts_with("No source"),
"first wrapped segment must keep the 2-space gutter; got {first:?}"
);
}
#[test]
fn wrap_styled_line_hangs_list_continuation_under_marker() {
let line = Line::from(vec![
Span::raw(" "), Span::raw(" "), Span::raw("• "), Span::raw("alpha beta gamma delta epsilon zeta eta theta iota".to_string()),
]);
let wrapped = wrap_styled_line(line, 24, 6);
assert!(wrapped.len() >= 2, "should wrap");
assert!(
first_segment_text(&wrapped).starts_with(" • "),
"first segment keeps gutter + nesting + marker"
);
for cont in &wrapped[1..] {
let t: String = cont.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
t.starts_with(" ") && t.chars().nth(6).is_some_and(|c| c != ' '),
"continuation hangs under the item text at col 6; got {t:?}"
);
}
}
#[test]
fn wrap_styled_line_keeps_bullet_at_column_zero() {
let line = Line::from(vec![
Span::raw("● "),
Span::raw(
"a fairly long first line of a message that definitely needs to wrap".to_string(),
),
]);
let wrapped = wrap_styled_line(line, 25, 2);
assert!(wrapped.len() >= 2, "should wrap");
assert!(
first_segment_text(&wrapped).starts_with('●'),
"bullet must stay at column 0"
);
}
#[test]
fn wrap_text_with_indent_uses_display_width_for_cjk() {
let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
assert_eq!(
wrapped.len(),
1,
"CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
wrapped.len(),
wrapped
);
assert_eq!(wrapped[0].trim_start(), "你好世界");
}
#[test]
fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
assert!(
wrapped.len() >= 2,
"mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
wrapped.len(),
wrapped
);
}
#[test]
fn clamp_to_u16_saturates_past_u16_max() {
assert_eq!(clamp_to_u16(0), 0);
assert_eq!(clamp_to_u16(65_535), u16::MAX);
assert_eq!(clamp_to_u16(65_536), u16::MAX);
assert_eq!(clamp_to_u16(1_000_000), u16::MAX);
}
#[test]
fn wrap_text_with_indent_hard_breaks_overlong_token() {
let token = "x".repeat(100);
let width = 20;
let wrapped = wrap_text_with_indent(&token, width, 2, 2);
assert!(
wrapped.len() >= 5,
"a 100-cell token at width 20 must span many rows; got {}",
wrapped.len()
);
for line in &wrapped {
assert!(
line.chars().count() <= width,
"no wrapped row may exceed the width; got {:?} ({} cells)",
line,
line.chars().count()
);
}
let joined: String = wrapped.iter().map(|l| l.trim_start()).collect();
assert_eq!(
joined, token,
"hard-break must preserve the token's content"
);
}
#[test]
fn wrap_styled_line_hard_breaks_overlong_token() {
let token = "y".repeat(90);
let style = Style::new().fg(ratatui::style::Color::Red);
let line = Line::from(vec![Span::raw(" "), Span::styled(token.clone(), style)]);
let width = 24;
let wrapped = wrap_styled_line(line, width, 2);
assert!(
wrapped.len() >= 4,
"must hard-break across rows; got {}",
wrapped.len()
);
let mut reconstructed = String::new();
for l in &wrapped {
let row_cells: usize = l.spans.iter().map(|s| s.content.chars().count()).sum();
assert!(
row_cells <= width,
"row exceeds width: {row_cells} > {width}"
);
for s in &l.spans {
if s.content.trim().is_empty() {
continue;
}
assert_eq!(
s.style.fg,
Some(ratatui::style::Color::Red),
"hard-break must preserve the span style"
);
reconstructed.push_str(s.content.as_ref());
}
}
assert_eq!(reconstructed, token, "hard-break must preserve the token");
}
#[test]
fn wrap_styled_line_separator_before_styled_span_is_unstyled() {
let underlined = Style::new().add_modifier(ratatui::style::Modifier::UNDERLINED);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("some filler words long enough to force a wrap here "),
Span::styled("underlined-link-text", underlined),
Span::raw(" and a bit more trailing filler after the link"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
for l in &wrapped {
for s in &l.spans {
if s.content.chars().all(|c| c == ' ') {
assert_eq!(
s.style,
Style::default(),
"whitespace span {:?} must be unstyled",
s.content
);
}
}
}
}
#[test]
fn wrap_styled_line_no_phantom_space_at_span_boundary() {
let dim = Style::new().fg(ratatui::style::Color::DarkGray);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("filler text that pushes the line well past the width limit "),
Span::styled("(https://example.com)".to_string(), dim),
Span::raw("."),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let text: String = wrapped
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains("(https://example.com)."),
"period must stay glued to the URL suffix; got {text:?}"
);
assert!(
!text.contains("(https://example.com) ."),
"no phantom space before the period; got {text:?}"
);
}
#[test]
fn wrap_styled_line_keeps_mid_word_style_change_glued() {
let bold = Style::new().add_modifier(ratatui::style::Modifier::BOLD);
let line = Line::from(vec![
Span::raw(" "),
Span::raw("leading filler words to force wrapping "),
Span::styled("bold", bold),
Span::raw("suffix"),
Span::raw(" trailing filler words to force more wrapping"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let rows: Vec<String> = wrapped
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(
rows.iter().filter(|r| r.contains("boldsuffix")).count(),
1,
"glued token must land whole on exactly one row; rows: {rows:?}"
);
for l in &wrapped {
for s in &l.spans {
if s.content.as_ref() == "bold" {
assert_eq!(s.style, bold, "bold fragment keeps its modifier");
}
if s.content.as_ref() == "suffix" {
assert_eq!(s.style, Style::default(), "suffix fragment stays plain");
}
}
}
}
#[test]
fn wrap_styled_line_hard_breaks_multi_fragment_token_preserving_styles() {
let red = Style::new().fg(ratatui::style::Color::Red);
let blue = Style::new().fg(ratatui::style::Color::Blue);
let line = Line::from(vec![
Span::raw(" "),
Span::styled("a".repeat(40), red),
Span::styled("b".repeat(40), blue),
]);
let width = 24;
let wrapped = wrap_styled_line(line, width, 2);
assert!(
wrapped.len() >= 4,
"80-cell token at width 24 must span >= 4 rows; got {}",
wrapped.len()
);
let mut reconstructed = String::new();
for l in &wrapped {
let row_cells: usize = l.spans.iter().map(|s| s.content.width()).sum();
assert!(
row_cells <= width,
"row exceeds width: {row_cells} > {width}"
);
for s in &l.spans {
if s.content.trim().is_empty() {
continue;
}
let expected = if s.content.contains('a') { red } else { blue };
assert!(
!(s.content.contains('a') && s.content.contains('b')),
"fragments must not merge across the style boundary"
);
assert_eq!(s.style, expected, "fragment style preserved across break");
reconstructed.push_str(s.content.as_ref());
}
}
assert_eq!(
reconstructed,
format!("{}{}", "a".repeat(40), "b".repeat(40)),
"hard-break must preserve the whole glued token"
);
}
#[test]
fn wrap_styled_line_whitespace_only_span_is_word_boundary() {
let line = Line::from(vec![
Span::raw(" "),
Span::raw("filler words that push this line past the wrap width "),
Span::raw("foo"),
Span::raw(" "),
Span::raw("bar"),
]);
let wrapped = wrap_styled_line(line, 30, 2);
assert!(wrapped.len() >= 2, "fixture must actually wrap");
let text: String = wrapped
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("foo bar") || text.contains("foo\n bar"),
"whitespace-only span must keep the words apart; got {text:?}"
);
assert!(
!text.contains("foobar"),
"words must not glue; got {text:?}"
);
}
#[test]
fn frame_memo_hit_matches_miss() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = Theme::dark();
let messages = vec![
ChatMessage::assistant(
"# Heading\n\nSome **bold** prose long enough that it wraps across \
this narrow viewport more than once.\n\n- a list item that also \
runs past the edge so it wraps\n- second item",
),
ChatMessage::assistant("Short follow-up."),
];
let (width, height): (u16, u16) = (34, 30);
let mut cache = FxHashMap::default();
let mut state = ChatState::new();
let render = |state: &mut ChatState, cache: &mut FxHashMap<u64, Vec<Line<'static>>>| {
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
term.draw(|f| {
let widget = ChatWidget {
messages: &messages,
theme: &theme,
wrapped_line_cache: cache,
show_reasoning: true,
blink_on: true,
};
f.render_stateful_widget(widget, Rect::new(0, 0, width, height), state);
})
.unwrap();
term.backend().buffer().clone()
};
let miss = render(&mut state, &mut cache);
assert!(
state.frame_memo.is_some(),
"first render must populate the frame memo"
);
let hit = render(&mut state, &mut cache);
assert_eq!(
miss, hit,
"frame-memo hit must render identically to the miss"
);
assert!(
!state.last_rendered_rows.is_empty(),
"memo hit must preserve last_rendered_rows from the miss"
);
}
#[test]
fn append_action_duration_handles_empty_base() {
assert_eq!(
append_action_duration(String::new(), Some(0.035)),
"took 35ms"
);
assert_eq!(
append_action_duration("3 lines read".to_string(), Some(1.25)),
"3 lines read, took 1.2s"
);
assert_eq!(append_action_duration(String::new(), None), "");
}
}