use std::hash::{Hash, Hasher};
use ratatui::text::{Line, Span};
use crate::{
render::terminal::TerminalCapabilities,
theme::Theme,
widgets::{
chat::{ChatMessage, ContentBlock, MessageRole, StreamingState, ToolCallStatus},
tool_renderer::{format_tool_call, format_tool_result},
},
};
use super::{Component, Container, LiveRegion, RenderResult, style::styled_line_to_ansi};
struct MessageComponent {
message: ChatMessage,
streaming: bool,
theme: Theme,
caps: TerminalCapabilities,
revision: u64,
}
impl MessageComponent {
fn new(
message: ChatMessage,
streaming: bool,
theme: &Theme,
caps: &TerminalCapabilities,
) -> Self {
Self {
revision: message_revision(&message),
message,
streaming,
theme: theme.clone(),
caps: caps.clone(),
}
}
fn styled_lines(&self, width: u16) -> Vec<Line<'static>> {
let styles = self.theme.to_styles();
let mut lines = Vec::new();
let role_style = match self.message.role {
MessageRole::User => styles.primary,
MessageRole::Assistant => styles.normal,
MessageRole::System => styles.muted,
};
for block in &self.message.content_blocks {
match block {
ContentBlock::Text { content } => {
let mut rendered =
crate::widgets::chat::markdown::md_lines(content, width, &styles);
for line in &mut rendered {
line.style = role_style.patch(line.style);
}
lines.extend(rendered);
}
ContentBlock::Thinking { content, collapsed } => {
let marker = if *collapsed {
styles.symbols.nav_expand
} else {
styles.symbols.nav_collapse
};
lines.push(Line::from(Span::styled(
format!("{marker} Thinking"),
styles.muted,
)));
if !collapsed {
lines.extend(crate::widgets::chat::markdown::md_lines(
content,
width.saturating_sub(2),
&styles,
));
}
}
ContentBlock::ToolCall {
name,
arguments,
result,
status,
duration,
..
} => {
lines.extend(format_tool_call(name, arguments, width as usize, &styles));
if matches!(status, ToolCallStatus::Executing) {
lines.push(Line::from(Span::styled(
format!(
" {} running{}",
styles.symbols.status_running,
duration
.as_deref()
.map_or(String::new(), |d| format!(" · {d}"))
),
styles.muted,
)));
}
if let Some((output, is_error)) = result {
lines.extend(format_tool_result(
name,
output,
*is_error,
Some(arguments),
width as usize,
&styles,
));
}
}
ContentBlock::ToolResult {
tool_name,
content,
is_error,
} => {
lines.extend(format_tool_result(
tool_name,
content,
*is_error,
None,
width as usize,
&styles,
));
}
ContentBlock::Error {
title,
message,
retryable,
} => {
let suffix = if *retryable { " (retryable)" } else { "" };
lines.push(Line::from(Span::styled(
format!("{} {title}{suffix}", styles.symbols.status_error),
styles.error,
)));
lines.extend(
message
.lines()
.map(|line| Line::from(Span::styled(line.to_string(), styles.error))),
);
}
ContentBlock::Image {
mime_type,
base64_data,
} => {
lines.push(Line::from(Span::styled(
format!(
"{} image {mime_type} ({} bytes)",
styles.symbols.icon_file,
base64_data.len()
),
styles.muted,
)));
}
ContentBlock::Dashboard { info } => {
lines.extend(crate::widgets::chat::dashboard::dashboard_lines(
info, width, &styles,
));
}
ContentBlock::Advisory { body, severity, .. } => {
let (label, badge_style) = match severity {
crate::widgets::chat::types::AdvisorSeverity::Nit => ("NIT", styles.muted),
crate::widgets::chat::types::AdvisorSeverity::Concern => {
("CONCERN", styles.warning)
}
crate::widgets::chat::types::AdvisorSeverity::Blocker => {
("BLOCKER", styles.error)
}
};
let prefix = format!("[{}] ", label);
let body_style = match severity {
crate::widgets::chat::types::AdvisorSeverity::Nit => styles.muted,
crate::widgets::chat::types::AdvisorSeverity::Concern => styles.warning,
crate::widgets::chat::types::AdvisorSeverity::Blocker => styles.error,
};
lines.push(Line::from(vec![
Span::styled(prefix, badge_style),
Span::styled(body.clone(), body_style),
]));
}
}
}
lines
}
}
impl Component for MessageComponent {
fn render(&self, width: u16) -> RenderResult {
RenderResult::new(
self.styled_lines(width)
.iter()
.map(|line| styled_line_to_ansi(line, &self.caps))
.collect(),
)
}
fn revision(&self) -> u64 {
self.revision
}
fn live_region(&self) -> LiveRegion {
if self.streaming {
LiveRegion::Mutable { start: 0 }
} else {
LiveRegion::None
}
}
}
fn message_revision(message: &ChatMessage) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
format!("{message:?}").hash(&mut hasher);
hasher.finish()
}
pub struct TranscriptRenderer {
container: Container,
fingerprint: u64,
}
impl TranscriptRenderer {
#[must_use]
pub fn new() -> Self {
Self {
container: Container::new(),
fingerprint: 0,
}
}
pub fn sync(
&mut self,
messages: &[ChatMessage],
streaming: Option<&StreamingState>,
theme: &Theme,
caps: &TerminalCapabilities,
) {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
format!("{theme:?}").hash(&mut hasher);
format!("{caps:?}").hash(&mut hasher);
for message in messages {
message_revision(message).hash(&mut hasher);
}
streaming
.map(|s| message_revision(&s.message))
.hash(&mut hasher);
let fingerprint = hasher.finish();
if fingerprint == self.fingerprint {
return;
}
self.container.clear();
for message in messages {
self.container.add(Box::new(MessageComponent::new(
message.clone(),
false,
theme,
caps,
)));
}
if let Some(streaming) = streaming {
self.container.add(Box::new(MessageComponent::new(
streaming.message.clone(),
true,
theme,
caps,
)));
}
self.fingerprint = fingerprint;
}
pub fn compose(&mut self, width: u16) -> (&RenderResult, LiveRegion) {
self.container.compose(width)
}
}
impl Default for TranscriptRenderer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(block: ContentBlock) -> ChatMessage {
ChatMessage {
role: MessageRole::Assistant,
content_blocks: vec![block],
timestamp: 0,
}
}
#[test]
fn text_wraps_to_width_and_stream_is_live() {
let mut renderer = TranscriptRenderer::new();
let streaming = StreamingState {
message: msg(ContentBlock::Text {
content: "one two three four".into(),
}),
};
renderer.sync(
&[],
Some(&streaming),
&Theme::dark(),
&TerminalCapabilities::default(),
);
let (result, live) = renderer.compose(8);
assert!(result.lines.len() > 1);
assert_eq!(live, LiveRegion::Mutable { start: 0 });
}
#[test]
fn every_block_variant_renders() {
let blocks = vec![
ContentBlock::Thinking {
content: "reason".into(),
collapsed: false,
},
ContentBlock::ToolCall {
id: "1".into(),
name: "read".into(),
arguments: "{\"path\":\"a\"}".into(),
result: Some(("ok".into(), false)),
status: ToolCallStatus::Done,
duration: Some("1ms".into()),
},
ContentBlock::ToolResult {
tool_name: "read".into(),
content: "ok".into(),
is_error: false,
},
ContentBlock::Error {
title: "bad".into(),
message: "detail".into(),
retryable: true,
},
ContentBlock::Image {
mime_type: "image/png".into(),
base64_data: "YWJj".into(),
},
ContentBlock::Advisory {
body: "consider revisiting tests".into(),
severity: crate::widgets::chat::types::AdvisorSeverity::Concern,
timestamp_ms: 1_700_000_000_000,
},
];
let messages: Vec<_> = blocks.into_iter().map(msg).collect();
let mut renderer = TranscriptRenderer::new();
renderer.sync(
&messages,
None,
&Theme::dark(),
&TerminalCapabilities::default(),
);
let (result, live) = renderer.compose(80);
let text = result.lines.join("\n");
assert_eq!(live, LiveRegion::None);
for needle in ["Thinking", "read", "bad", "image/png", "[CONCERN]"] {
assert!(text.contains(needle), "missing {needle}");
}
}
}