@ -92,7 +92,9 @@ const TOOL_INPUT_TRUNCATE: usize = 120;
///
/// Thinking blocks are skipped. Tool results are truncated to [`TOOL_RESULT_TRUNCATE`]
/// chars. Tool inputs are truncated to [`TOOL_INPUT_TRUNCATE`] chars.
pub fn build_chat_lines ( entries : & [ RawEntry ] ) -> Vec < Line < ' static > > {
///
/// When `color_enabled` is `false` all spans are rendered without colour styling.
pub fn build_chat_lines ( entries : & [ RawEntry ] , color_enabled : bool ) -> Vec < Line < ' static > > {
let mut lines : Vec < Line < ' static > > = Vec ::new ( ) ;
for entry in entries {
@ -101,26 +103,42 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
} ;
let role = msg . role . as_deref ( ) . unwrap_or ( "?" ) . to_string ( ) ;
// Choose a ratatui role label style based on the role name.
let role_style = if color_enabled {
match role . as_str ( ) {
"assistant" = > Style ::default ( ) . fg ( Color ::Rgb ( 255 , 140 , 0 ) ) , // orange
"user" = > Style ::default ( ) . fg ( Color ::Rgb ( 170 , 170 , 170 ) ) , // grey
_ = > Style ::default ( ) ,
}
} else {
Style ::default ( )
} ;
match & msg . content {
None = > { }
Some ( MessageContent ::Text ( t ) ) = > {
let prefix = format! ( "[{role}]: " ) ;
let prefix _label = format! ( "[{role}]: " ) ;
let text = t . clone ( ) ;
// Split on newlines so each source line becomes its own ratatui Line.
let mut first = true ;
for src_line in text . lines ( ) {
let line_text = if first {
if first {
first = false ;
format! ( "{prefix}{src_line}" )
lines . push ( Line ::from ( vec! [
Span ::styled ( prefix_label . clone ( ) , role_style ) ,
Span ::raw ( src_line . to_string ( ) ) ,
] ) ) ;
} else {
// Indent continuation lines by the prefix width.
format! ( "{}{src_line}" , " " . repeat ( prefix . len ( ) ) )
} ;
lines . push ( Line ::from ( line_text ) ) ;
lines . push ( Line ::from ( format! (
"{}{src_line}" ,
" " . repeat ( prefix_label . len ( ) )
) ) ) ;
}
}
if first {
// Empty text — still emit the prefix.
lines . push ( Line ::from ( prefix) ) ;
lines . push ( Line ::from ( Span::styled ( prefix_label, role_style ) ) ) ;
}
}
Some ( MessageContent ::Blocks ( blocks ) ) = > {
@ -130,19 +148,24 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
// Hidden by default.
}
ContentBlock ::Text { text } = > {
let prefix = format! ( "[{role}]: " ) ;
let prefix _label = format! ( "[{role}]: " ) ;
let mut first = true ;
for src_line in text . lines ( ) {
let line_text = if first {
if first {
first = false ;
format! ( "{prefix}{src_line}" )
lines . push ( Line ::from ( vec! [
Span ::styled ( prefix_label . clone ( ) , role_style ) ,
Span ::raw ( src_line . to_string ( ) ) ,
] ) ) ;
} else {
format! ( "{}{src_line}" , " " . repeat ( prefix . len ( ) ) )
} ;
lines . push ( Line ::from ( line_text ) ) ;
lines . push ( Line ::from ( format! (
"{}{src_line}" ,
" " . repeat ( prefix_label . len ( ) )
) ) ) ;
}
}
if first {
lines . push ( Line ::from ( prefix) ) ;
lines . push ( Line ::from ( Span::styled ( prefix_label, role_style ) ) ) ;
}
}
ContentBlock ::ToolUse { name , input , .. } = > {
@ -151,9 +174,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
let truncated = truncate_str ( & input_str , TOOL_INPUT_TRUNCATE ) ;
let ellipsis =
if input_str . len ( ) > TOOL_INPUT_TRUNCATE { "…" } else { "" } ;
let tool_style = if color_enabled {
Style ::default ( ) . fg ( Color ::Cyan )
} else {
Style ::default ( )
} ;
lines . push ( Line ::from ( Span ::styled (
format! ( "[tool: {name}] {truncated}{ellipsis}" ) ,
Style ::default ( ) . fg ( Color ::Cyan ) ,
tool_style ,
) ) ) ;
}
ContentBlock ::ToolResult {
@ -173,10 +201,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
let truncated = truncate_str ( & preview , TOOL_RESULT_TRUNCATE ) ;
let ellipsis =
if preview . len ( ) > TOOL_RESULT_TRUNCATE { "…" } else { "" } ;
let style = if is_error . unwrap_or ( false ) {
let style = if color_enabled {
if is_error . unwrap_or ( false ) {
Style ::default ( ) . fg ( Color ::Red )
} else {
Style ::default ( ) . fg ( Color ::Green )
}
} else {
Style ::default ( )
} ;
lines . push ( Line ::from ( Span ::styled (
format! ( "[tool_result{err_flag}]: {truncated}{ellipsis}" ) ,
@ -184,9 +216,14 @@ pub fn build_chat_lines(entries: &[RawEntry]) -> Vec<Line<'static>> {
) ) ) ;
}
ContentBlock ::Image { .. } = > {
let img_style = if color_enabled {
Style ::default ( ) . fg ( Color ::Yellow )
} else {
Style ::default ( )
} ;
lines . push ( Line ::from ( Span ::styled (
"[image]" . to_string ( ) ,
Style ::default ( ) . fg ( Color ::Yellow ) ,
img_style ,
) ) ) ;
}
ContentBlock ::Unknown = > {
@ -255,7 +292,7 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
. split ( chunks [ 1 ] ) ;
// ── Chat log ─────────────────────────────────────────────────────────────
let chat_lines = build_chat_lines ( & state . transcript_entries );
let chat_lines = build_chat_lines ( & state . transcript_entries , state . color_enabled );
let chat_border_style = if state . focus = = Focus ::ChatLog {
Style ::default ( ) . fg ( Color ::Yellow )
@ -342,7 +379,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
KeyCode ::Tab = > {
state . focus = match state . focus {
Focus ::ChatLog = > Focus ::SubagentsPanel ,
Focus ::SubagentsPanel => Focus ::ChatLog ,
Focus ::SubagentsPanel | Focus ::FilterInput => Focus ::ChatLog ,
} ;
true
}
@ -367,7 +404,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state . subagent_selected = state . subagent_selected . saturating_sub ( 1 ) ;
true
}
Focus ::ChatLog => {
Focus ::ChatLog | Focus ::FilterInput => {
state . transcript_scroll = state . transcript_scroll . saturating_sub ( 1 ) ;
true
}
@ -381,7 +418,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
true
}
Focus ::ChatLog => {
Focus ::ChatLog | Focus ::FilterInput => {
state . transcript_scroll = state . transcript_scroll . saturating_add ( 1 ) ;
true
}
@ -642,14 +679,14 @@ mod tests {
#[ test ]
fn chat_lines_empty_entries ( ) {
let lines = build_chat_lines ( & [ ] );
let lines = build_chat_lines ( & [ ] , false );
assert! ( lines . is_empty ( ) ) ;
}
#[ test ]
fn chat_lines_user_text ( ) {
let entry = user_text_entry ( "Hello, Claude!" ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert_eq! ( lines . len ( ) , 1 ) ;
let text : String = lines [ 0 ] . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
assert! ( text . contains ( "[user]: Hello, Claude!" ) ) ;
@ -658,7 +695,7 @@ mod tests {
#[ test ]
fn chat_lines_assistant_text ( ) {
let entry = assistant_text_entry ( "Here is my answer." ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert_eq! ( lines . len ( ) , 1 ) ;
let text : String = lines [ 0 ] . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
assert! ( text . contains ( "[assistant]: Here is my answer." ) ) ;
@ -667,7 +704,7 @@ mod tests {
#[ test ]
fn chat_lines_multiline_text ( ) {
let entry = user_text_entry ( "line1\nline2\nline3" ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert_eq! ( lines . len ( ) , 3 ) ;
}
@ -688,7 +725,7 @@ mod tests {
model : None ,
stop_reason : None ,
} ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
// Only the Text block should produce a line; Thinking is skipped.
assert_eq! ( lines . len ( ) , 1 ) ;
let text : String = lines [ 0 ] . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
@ -710,7 +747,7 @@ mod tests {
model : None ,
stop_reason : None ,
} ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert_eq! ( lines . len ( ) , 1 ) ;
let text : String = lines [ 0 ] . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
assert! ( text . contains ( "[tool: Read]" ) ) ;
@ -732,7 +769,7 @@ mod tests {
model : None ,
stop_reason : None ,
} ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert_eq! ( lines . len ( ) , 1 ) ;
let text : String = lines [ 0 ] . spans . iter ( ) . map ( | s | s . content . as_ref ( ) ) . collect ( ) ;
// Should contain the ellipsis marker.
@ -743,7 +780,7 @@ mod tests {
fn chat_lines_entries_without_message_skipped ( ) {
// A "system" entry with no message field should produce no chat lines.
let entry = make_entry ( "system" ) ;
let lines = build_chat_lines ( & [ entry ] );
let lines = build_chat_lines ( & [ entry ] , false );
assert! ( lines . is_empty ( ) ) ;
}