#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] //! Chat transcript panel rendering — tight inline log style. //! //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense, //! log-like transcript: each non-tool message gets a one-line //! `{role} {time} {content}` header with wrapped continuation lines //! aligned under the content column; `Role::Tool` messages render as a //! dim `↳`-prefixed sub-line attached to whatever came before, with no //! header of their own. A streaming spinner line is appended when a turn //! is in flight. The combined line list is sliced to the visible scroll //! window before rendering. //! //! Design: no per-message card/border/badge — role identity comes from a //! short colored label, and vertical space is reserved for a blank line //! only when the speaker actually changes (Tool sub-lines never count as //! a speaker change), keeping more history on screen at once. use ratatui::layout::Rect; use ratatui::style::{Color, Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; use crate::dto::chat::message::Role; /// Column width reserved for the `{role} {time} ` header prefix; wrapped /// continuation lines and Tool sub-lines indent to this width so content /// stays aligned under the first line's content column. const PREFIX_WIDTH: usize = 15; /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. fn split_spans_into_lines(spans: Vec>) -> Vec> { let mut lines = Vec::new(); let mut current_spans = Vec::new(); for span in spans { let text = span.content.as_ref(); let mut parts = text.split('\n').peekable(); while let Some(part) = parts.next() { if !part.is_empty() { current_spans.push(Span::styled(part.to_string(), span.style)); } if parts.peek().is_some() { lines.push(Line::from(std::mem::take(&mut current_spans))); } } } if !current_spans.is_empty() { lines.push(Line::from(current_spans)); } if lines.is_empty() { lines.push(Line::from(vec![])); } lines } fn role_accent_color(role: &Role) -> Color { match role { Role::User => Theme::ROLE_USER, Role::Assistant => Theme::ROLE_ASSISTANT, Role::System => Theme::ROLE_SYSTEM, Role::Tool => Theme::ROLE_TOOL, } } /// Short lowercase label for the `{role} {time}` header column. Callers pad /// it to a fixed width themselves (not padded here so tests can assert the /// raw label). fn format_role_label(role: &Role) -> &'static str { match role { Role::User => "👤 you ", Role::Assistant => "🤖 ai ", Role::System => "💻 sys ", Role::Tool => "🔧 tool", } } fn format_timestamp(ts: i64) -> String { if ts <= 0 { return String::new(); } let secs = ts / 1000; let mins = (secs / 60) % 60; let hrs = (secs / 3600) % 24; format!("{hrs:02}:{mins:02}") } /// Whether a blank separator line should be inserted before rendering a /// message from `role`, given the last non-Tool role that was rendered. /// /// Why: `Role::Tool` messages render as an attached sub-line (see /// `draw_chat`) and must never be passed as `prev_role` — a Tool message /// never triggers a separator, and it never causes one to be inserted /// before the next real turn either. fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool { false // User requested zsh-style compactness (no empty lines between speakers) } /// Render the scrollable chat transcript panel in tight inline-log style. #[allow(clippy::too_many_lines)] pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); // Wrap width for content: total width minus the header/indent prefix // and minus the panel's left+right border columns. let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); let mut display_lines: Vec = Vec::new(); let mut prev_role: Option = None; let title = if messages.is_empty() { String::from(" 💬 Chat ") } else { format!(" 💬 Chat [{} msgs] ", messages.len()) }; for msg in messages { let is_last = std::ptr::eq(msg, messages.last().unwrap()); // Tool messages render as a dim sub-line attached to whatever came // before — no header, no speaker-change bookkeeping. Content is run // through the same render_markdown + split_spans_into_lines pipeline // as every other role so multi-line tool output (bash stdout, grep // matches, diffs) becomes real wrapped `Line`s instead of a literal // `\n` inside one Span; every rendered span is then re-styled dim // italic to preserve the original single-line look. if msg.role == Role::Tool { let content = if msg.content.trim().is_empty() { "(tool execution)".to_string() } else { msg.content.clone() }; let dim = Style::default().fg(Theme::TEXT_DIM); let dim_italic = dim.add_modifier(Modifier::ITALIC); let content_spans = super::markdown::render_markdown(&content, content_width); let content_lines = split_spans_into_lines(content_spans); let mut lines_iter = content_lines.into_iter(); let first_spans = lines_iter.next().map_or_else(Vec::new, |line| { line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic)).collect() }); let mut spans = vec![ Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim), ]; spans.extend(first_spans); display_lines.push(Line::from(spans)); for line in lines_iter { let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; spans.extend(line.spans.into_iter().map(|s| Span::styled(s.content, dim_italic))); display_lines.push(Line::from(spans)); } continue; } if needs_speaker_separator(prev_role.as_ref(), &msg.role) { display_lines.push(Line::from(Span::raw(""))); } prev_role = Some(msg.role.clone()); let accent = role_accent_color(&msg.role); let label = format_role_label(&msg.role); let ts_str = format_timestamp(msg.timestamp); let header_prefix = vec![ Span::styled(format!("{label} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)), Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)), ]; let content_str = if msg.content.trim().is_empty() { if is_last && state.turn_in_flight() { "(streaming...)".to_string() } else { "(tool execution)".to_string() } } else { msg.content.clone() }; let content_spans = super::markdown::render_markdown(&content_str, content_width); let content_lines = split_spans_into_lines(content_spans); let mut lines_iter = content_lines.into_iter(); if let Some(first) = lines_iter.next() { let mut spans = header_prefix; spans.extend(first.spans); display_lines.push(Line::from(spans)); } else { display_lines.push(Line::from(header_prefix)); } for line in lines_iter { let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; spans.extend(line.spans); display_lines.push(Line::from(spans)); } } // ── Streaming indicator ────────────────────────────────────────────── if state.turn_in_flight() { let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let spinner = spinner_frames[frame_idx]; if needs_speaker_separator(prev_role.as_ref(), &Role::Assistant) { display_lines.push(Line::from(Span::raw(""))); } display_lines.push(Line::from(vec![ Span::styled( format!("{} ", format_role_label(&Role::Assistant)), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), ), Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)), ])); } // ── Scrolling ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD))); let total = display_lines.len(); let max_offset = total.saturating_sub(max_visible); let offset = scroll_offset.min(max_offset); let end_idx = total.saturating_sub(offset); let start_idx = end_idx.saturating_sub(max_visible); let visible: Vec = if start_idx < end_idx && start_idx < total { display_lines[start_idx..end_idx].to_vec() } else { display_lines[total.saturating_sub(max_visible)..total].to_vec() }; let scroll_pct = if total > max_visible { ((offset as f64 / max_offset as f64) * 100.0) as u8 } else { 0 }; let block = if scroll_pct > 0 { let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct); Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Theme::BORDER)) .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::BOLD))) } else { block }; let paragraph = Paragraph::new(visible) .block(block) .style(Style::default().bg(Theme::BG)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } #[cfg(test)] mod tests { use super::*; #[test] fn no_separator_when_no_previous_message() { assert!(!needs_speaker_separator(None, &Role::User)); } #[test] fn no_separator_when_same_speaker_repeats() { assert!(!needs_speaker_separator(Some(&Role::Assistant), &Role::Assistant)); } #[test] fn no_separator_when_speaker_changes_because_zsh_style() { assert!(!needs_speaker_separator(Some(&Role::User), &Role::Assistant)); } #[test] fn role_labels_include_emojis_and_padding() { assert_eq!(format_role_label(&Role::User), "👤 you "); assert_eq!(format_role_label(&Role::Assistant), "🤖 ai "); assert_eq!(format_role_label(&Role::System), "💻 sys "); assert_eq!(format_role_label(&Role::Tool), "🔧 tool"); } }