2026-07-13 05:42:17 +07:00
|
|
|
//! Chat transcript panel rendering — message cards with role badges.
|
2026-07-12 11:28:39 +07:00
|
|
|
//!
|
|
|
|
|
//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a
|
2026-07-13 05:42:17 +07:00
|
|
|
//! visually rich transcript where each message is rendered as a "card"
|
|
|
|
|
//! with a role-colored left accent bar, a role badge pill, timestamp,
|
|
|
|
|
//! and markdown body. 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.
|
2026-07-12 11:28:39 +07:00
|
|
|
//!
|
2026-07-13 05:42:17 +07:00
|
|
|
//! Design: messages are visually separated with vertical spacing, role
|
|
|
|
|
//! badges are colored pills on the left, and each message has a thin
|
|
|
|
|
//! role-colored border on its left side for quick visual scanning.
|
2026-07-12 11:28:39 +07:00
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use ratatui::layout::Rect;
|
|
|
|
|
use ratatui::style::{Style, Modifier};
|
|
|
|
|
use ratatui::text::{Line, Span};
|
|
|
|
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
|
|
|
|
use ratatui::Frame;
|
|
|
|
|
use super::theme::Theme;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
|
2026-07-12 04:01:10 +07:00
|
|
|
fn split_spans_into_lines<'a>(spans: Vec<Span<'a>>) -> Vec<Line<'a>> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str {
|
2026-07-11 13:16:10 +07:00
|
|
|
match role {
|
2026-07-13 05:42:17 +07:00
|
|
|
crate::dto::chat::message::Role::User => " YOU ",
|
|
|
|
|
crate::dto::chat::message::Role::Assistant => " AI ",
|
|
|
|
|
crate::dto::chat::message::Role::System => " SYS ",
|
|
|
|
|
crate::dto::chat::message::Role::Tool => " TOOL ",
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color {
|
2026-07-11 13:16:10 +07:00
|
|
|
match role {
|
2026-07-13 05:42:17 +07:00
|
|
|
crate::dto::chat::message::Role::User => Theme::ROLE_USER,
|
|
|
|
|
crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT,
|
|
|
|
|
crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM,
|
|
|
|
|
crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn role_label(role: &crate::dto::chat::message::Role) -> &'static str {
|
|
|
|
|
match role {
|
|
|
|
|
crate::dto::chat::message::Role::User => "You",
|
|
|
|
|
crate::dto::chat::message::Role::Assistant => "Assistant",
|
|
|
|
|
crate::dto::chat::message::Role::System => "System",
|
|
|
|
|
crate::dto::chat::message::Role::Tool => "Tool Call",
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn format_timestamp(ts: i64) -> String {
|
2026-07-13 05:42:17 +07:00
|
|
|
if ts <= 0 { return String::new(); }
|
2026-07-11 13:16:10 +07:00
|
|
|
let secs = ts / 1000;
|
|
|
|
|
let mins = (secs / 60) % 60;
|
|
|
|
|
let hrs = (secs / 3600) % 24;
|
|
|
|
|
format!("{:02}:{:02}", hrs, mins)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
/// Render the scrollable chat transcript panel with message card styling.
|
2026-07-11 13:16:10 +07:00
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
let mut display_lines: Vec<Line> = Vec::new();
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Header ───────────────────────────────────────────────────────────
|
|
|
|
|
let title = if messages.is_empty() {
|
|
|
|
|
String::from(" Chat ")
|
|
|
|
|
} else {
|
|
|
|
|
format!(" Chat [{} msgs]", messages.len())
|
|
|
|
|
};
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Render messages as cards ─────────────────────────────────────────
|
|
|
|
|
for (_msg_idx, msg) in messages.iter().enumerate() {
|
|
|
|
|
let accent = role_accent_color(&msg.role);
|
2026-07-11 13:16:10 +07:00
|
|
|
let badge = role_badge(&msg.role);
|
2026-07-13 05:42:17 +07:00
|
|
|
let label = role_label(&msg.role);
|
|
|
|
|
let ts_str = format_timestamp(msg.timestamp);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Role header line ─────────────────────────────────────────────
|
|
|
|
|
// Left accent bar + badge pill + role name + timestamp
|
2026-07-11 13:16:10 +07:00
|
|
|
let header = Line::from(vec![
|
2026-07-13 05:42:17 +07:00
|
|
|
// Thin accent bar on the left
|
2026-07-11 13:16:10 +07:00
|
|
|
Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
"▎",
|
|
|
|
|
Style::default().fg(accent),
|
2026-07-11 13:16:10 +07:00
|
|
|
),
|
2026-07-13 05:42:17 +07:00
|
|
|
// Role badge pill
|
2026-07-11 13:16:10 +07:00
|
|
|
Span::styled(
|
2026-07-13 05:42:17 +07:00
|
|
|
badge,
|
|
|
|
|
Style::default()
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
.bg(accent)
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
),
|
|
|
|
|
// Role name
|
|
|
|
|
Span::styled(
|
|
|
|
|
format!(" {}", label),
|
|
|
|
|
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
|
|
|
|
),
|
|
|
|
|
// Timestamp
|
|
|
|
|
Span::styled(
|
|
|
|
|
if ts_str.is_empty() { String::new() } else { format!(" {}", ts_str) },
|
|
|
|
|
Style::default().fg(Theme::TEXT_DIM),
|
2026-07-11 13:16:10 +07:00
|
|
|
),
|
|
|
|
|
]);
|
2026-07-13 05:42:17 +07:00
|
|
|
display_lines.push(header);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Message content ──────────────────────────────────────────────
|
2026-07-12 03:37:27 +07:00
|
|
|
let is_last = std::ptr::eq(msg, messages.last().unwrap());
|
|
|
|
|
let content_str = if msg.content.trim().is_empty() {
|
|
|
|
|
if is_last && state.turn_in_flight() {
|
|
|
|
|
"(streaming...)".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
"(tool execution)".to_string()
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
} else {
|
|
|
|
|
msg.content.clone()
|
|
|
|
|
};
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
// Render markdown content with accent-colored prefix
|
|
|
|
|
let mut content_spans = vec![
|
|
|
|
|
Span::styled(" ", Style::default().fg(accent)),
|
|
|
|
|
];
|
2026-07-12 01:25:52 +07:00
|
|
|
content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
|
2026-07-12 04:01:10 +07:00
|
|
|
let message_lines = split_spans_into_lines(content_spans);
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 04:01:10 +07:00
|
|
|
for line in message_lines {
|
|
|
|
|
display_lines.push(line);
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
// ── Message separator ────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
display_lines.push(Line::from(Span::raw("")));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Streaming indicator ──────────────────────────────────────────────
|
2026-07-12 03:19:25 +07:00
|
|
|
if state.turn_in_flight() {
|
|
|
|
|
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
2026-07-13 05:42:17 +07:00
|
|
|
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
|
|
|
|
|
let spinner = spinner_frames[frame_idx];
|
|
|
|
|
|
2026-07-11 23:45:13 +07:00
|
|
|
display_lines.push(Line::from(vec![
|
2026-07-13 05:42:17 +07:00
|
|
|
Span::styled(
|
|
|
|
|
"▎",
|
|
|
|
|
Style::default().fg(Theme::ROLE_ASSISTANT),
|
|
|
|
|
),
|
|
|
|
|
Span::styled(
|
|
|
|
|
" AI ",
|
|
|
|
|
Style::default()
|
|
|
|
|
.fg(Theme::BG)
|
|
|
|
|
.bg(Theme::ROLE_ASSISTANT)
|
|
|
|
|
.add_modifier(Modifier::BOLD),
|
|
|
|
|
),
|
|
|
|
|
Span::styled(
|
|
|
|
|
format!(" {} ", spinner),
|
|
|
|
|
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
|
|
|
|
|
),
|
|
|
|
|
Span::styled(
|
|
|
|
|
"Generating...",
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
|
|
|
|
|
),
|
2026-07-11 23:45:13 +07:00
|
|
|
]));
|
|
|
|
|
display_lines.push(Line::from(Span::raw("")));
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Scrolling ────────────────────────────────────────────────────────
|
2026-07-11 13:16:10 +07:00
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(Theme::BORDER))
|
2026-07-13 05:42:17 +07:00
|
|
|
.title(Span::styled(
|
|
|
|
|
title,
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED),
|
|
|
|
|
));
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
let total = display_lines.len();
|
2026-07-11 23:45:13 +07:00
|
|
|
let max_offset = total.saturating_sub(max_visible);
|
|
|
|
|
let offset = scroll_offset.min(max_offset);
|
|
|
|
|
|
|
|
|
|
let end_idx = total.saturating_sub(offset);
|
2026-07-11 13:16:10 +07:00
|
|
|
let start_idx = end_idx.saturating_sub(max_visible);
|
2026-07-11 23:45:13 +07:00
|
|
|
let visible: Vec<Line> = if start_idx < end_idx && start_idx < total {
|
2026-07-11 13:16:10 +07:00
|
|
|
display_lines[start_idx..end_idx].to_vec()
|
|
|
|
|
} else {
|
2026-07-11 23:45:13 +07:00
|
|
|
display_lines[total.saturating_sub(max_visible)..total].to_vec()
|
2026-07-11 13:16:10 +07:00
|
|
|
};
|
|
|
|
|
|
2026-07-13 05:42:17 +07:00
|
|
|
// ── Scroll position indicator ────────────────────────────────────────
|
|
|
|
|
// Show a small percentage indicator in the title if scrolled
|
|
|
|
|
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_style(Style::default().fg(Theme::BORDER))
|
|
|
|
|
.title(Span::styled(
|
|
|
|
|
scroll_title,
|
|
|
|
|
Style::default().fg(Theme::TEXT_MUTED),
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
block
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
let paragraph = Paragraph::new(visible)
|
|
|
|
|
.block(block)
|
2026-07-13 05:42:17 +07:00
|
|
|
.style(Style::default().bg(Theme::BG))
|
2026-07-11 13:16:10 +07:00
|
|
|
.wrap(Wrap { trim: false });
|
|
|
|
|
|
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
|
}
|
2026-07-13 05:42:17 +07:00
|
|
|
|
|
|
|
|
// Need to import Color for role_accent_color
|
|
|
|
|
use ratatui::style::Color;
|