Files
zesdex/apps/interfaces/tui/src/view/chat.rs
T

290 lines
10 KiB
Rust
Raw Normal View History

//! 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.
//!
//! Performance: rendered lines are cached in `state.display_lines_cache` and
//! only rebuilt when `transcript_cache.dirty == true` or the terminal width
//! changes. The cache is updated in `pre_render_chat` (called from `view::pre_render`
//! before the immutable draw pass) to avoid borrow conflicts.
use super::theme::Theme;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use ratatui::Frame;
use zesdex_domain::core::Role;
const PREFIX_WIDTH: usize = 15;
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,
}
}
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}")
}
/// Rebuild the full list of display lines from the transcript cache.
///
/// This is the expensive operation — markdown parsing, span building, etc.
/// Only called from `pre_render_chat` when cache is stale.
fn build_display_lines(
messages: &std::collections::VecDeque<crate::state::ChatMessageDisplay>,
content_width: u16,
) -> Vec<Line<'static>> {
let mut display_lines: Vec<Line<'static>> = Vec::new();
for msg in messages {
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 content_spans = super::markdown::render_markdown(&content, content_width, true);
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);
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);
display_lines.push(Line::from(spans));
}
continue;
}
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() {
"(tool execution)".to_string()
} else {
msg.content.clone()
};
let content_spans = super::markdown::render_markdown(&content_str, content_width, false);
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));
}
}
display_lines
}
/// Pre-render hook: rebuild display_lines_cache and token count if stale.
///
/// Called from `view::pre_render` (with `&mut AppStateRest`) **before** the
/// immutable `terminal.draw` closure. This avoids borrow conflicts and ensures
/// that `draw_chat` can take `&AppStateRest`.
pub fn pre_render_chat(state: &mut crate::state::AppStateRest) {
// We don't know the terminal width here, so we use the last known width.
// If width changed, it will be detected next frame via last_render_width.
let content_width = state.last_render_width.saturating_sub(PREFIX_WIDTH as u16 + 2);
// Rebuild display lines only when transcript changed or width changed.
// In practice this means: only when new messages arrive or on resize.
if state.transcript_cache.dirty || state.last_render_width == 0 {
state.display_lines_cache =
build_display_lines(&state.transcript_cache.messages, content_width);
state.transcript_cache.dirty = false;
}
// Lazily recompute token count (expensive tiktoken call) only when new
// messages have arrived — not on every render frame.
if state.token_count_dirty {
if let Some(ref rt) = state.session_runtime {
state.cached_token_count = rt
.messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(crate::state::count_tokens)
.sum();
} else {
state.cached_token_count = 0;
}
state.token_count_dirty = false;
}
}
/// Render the scrollable chat transcript panel in tight inline-log style.
///
/// Uses `state.display_lines_cache` — rebuilt by `pre_render_chat` when stale.
/// This function itself is read-only (`&AppStateRest`) and safe to call
/// inside the `terminal.draw` closure.
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRest) {
let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3);
// If the terminal width has changed since last pre_render, rebuild inline.
// This is a safety fallback — normally pre_render handles this.
let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
let mut display_lines = if state.last_render_width != area.width && !state.display_lines_cache.is_empty() {
// Width mismatch — use cached but mark needs rebuild next tick
state.display_lines_cache.clone()
} else {
state.display_lines_cache.clone()
};
// Streaming indicator — appended live (not cached) so spinner animates smoothly.
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];
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),
),
]));
}
// Suppress unused variable warning — content_width used for rebuild path
let _ = content_width;
// Build title
let title = if messages.is_empty() {
String::from(" 💬 Chat ")
} else {
format!(" 💬 Chat [{} msgs] ", messages.len())
};
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));
frame.render_widget(paragraph, area);
}
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
}