289 lines
11 KiB
Rust
289 lines
11 KiB
Rust
#![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<Span<'_>>) -> Vec<Line<'_>> {
|
|
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<Line> = Vec::new();
|
|
let mut prev_role: Option<Role> = 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<Line> = 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");
|
|
}
|
|
}
|