Files
zesdex/apps/interfaces/tui/src/view/chat.rs
T
asepharyana 271721694b perf(tui): render streaming token secara inkremental + kurangi redraw sia-sia
- fix(view): token streaming kini benar-benar tampil — sebelumnya cache
  display_lines tidak pernah di-rebuild saat pesan terakhir berubah
  (msg_count == cached_count), jadi teks AI streaming tidak pernah muncul
  sampai pesan baru/resize
- perf(view): streaming kini hanya re-render pesan TERAKHIR (splice di
  batas cached_last_start) → O(konten baru) per token, bukan O(seluruh
  history); guard cached_last_len mencegah re-render pada frame spinner
  tanpa token baru
- perf(run): skip chrono::Utc::now() + drain toasts saat tidak ada toast
- perf(misc): drain_expired_toasts tidak lagi clone seluruh daftar toast
- perf(view): render_toasts fast-path saat toasts kosong
2026-08-27 22:23:28 +07:00

364 lines
14 KiB
Rust

//! Chat transcript panel rendering — tight inline log style.
//!
//! Performance design:
//! - `display_lines_cache` menyimpan Vec<Line> per pesan (indexed by message position)
//! - Saat pesan baru masuk, hanya pesan BARU yang di-render, bukan rebuild seluruh history
//! - `draw_chat` tidak pernah clone seluruh cache — hanya slice window visible yang di-ambil
//! - `mark_dirty()` di Tick hanya dipanggil jika ada event aktif atau spinner berjalan
use super::theme::Theme;
use crate::components::Component;
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;
#[derive(Default)]
pub struct ChatComponent;
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}")
}
/// Render satu pesan menjadi Vec<Line<'static>>.
/// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history.
fn render_one_message(
msg: &crate::state::ChatMessageDisplay,
content_width: u16,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
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, |l| l.spans);
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH)), Span::styled("↳ ", dim)];
spans.extend(first_spans);
lines.push(Line::from(spans));
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
return lines;
}
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 mut first_line_handled = false;
if !msg.reasoning.trim().is_empty() {
let reasoning_str = format!("[Thinking...]\n{}", msg.reasoning.trim());
let reasoning_spans =
super::markdown::render_markdown(&reasoning_str, content_width, false);
// Dim the reasoning text
let dimmed_spans: Vec<Span> = reasoning_spans
.into_iter()
.map(|mut s| {
s.style = s.style.fg(Theme::TEXT_DIM);
s
})
.collect();
let reasoning_lines = split_spans_into_lines(dimmed_spans);
let mut lines_iter = reasoning_lines.into_iter();
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix.clone();
spans.extend(first.spans);
lines.push(Line::from(spans));
first_line_handled = true;
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
let content_str = if msg.content.trim().is_empty() {
if msg.reasoning.trim().is_empty() {
"(tool execution)".to_string()
} else {
String::new()
}
} else {
msg.content.clone()
};
if !content_str.is_empty() {
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 !first_line_handled {
if let Some(first) = lines_iter.next() {
let mut spans = header_prefix.clone();
spans.extend(first.spans);
lines.push(Line::from(spans));
first_line_handled = true;
}
} else {
// Header already printed by reasoning, just indent
if let Some(first) = lines_iter.next() {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(first.spans);
lines.push(Line::from(spans));
}
}
for line in lines_iter {
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
}
if !first_line_handled {
lines.push(Line::from(header_prefix));
}
lines
}
impl Component for ChatComponent {
fn pre_render(&mut self, state: &mut crate::state::AppStateRest) {
let content_width = state
.last_render_width
.saturating_sub(PREFIX_WIDTH as u16 + 2);
let msg_count = state.transcript_cache.messages.len();
let cached_count = state.cached_msg_count;
let needs_full_rebuild = state.transcript_cache.dirty
&& (state.last_render_width == 0
|| msg_count < cached_count // pesan di-evict dari depan
|| state.render_width_at_cache != state.last_render_width); // resize
if needs_full_rebuild {
// Full rebuild: parse semua pesan dari nol
let mut all_lines: Vec<Line<'static>> = Vec::new();
for msg in &state.transcript_cache.messages {
let msg_lines = render_one_message(msg, content_width);
all_lines.extend(msg_lines);
}
state.display_lines_cache = all_lines;
state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.render_width_at_cache = state.last_render_width;
state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty && msg_count > cached_count {
// Incremental: hanya render pesan baru yang belum ada di cache
let new_msgs: Vec<_> = state
.transcript_cache
.messages
.iter()
.skip(cached_count)
.collect();
for msg in new_msgs {
let msg_lines = render_one_message(msg, content_width);
state.display_lines_cache.extend(msg_lines);
}
state.cached_msg_count = msg_count;
state.cached_last_start = state.display_lines_cache.len();
state.cached_last_len = last_msg_len(state);
state.transcript_cache.dirty = false;
} else if state.transcript_cache.dirty {
// Streaming update: message count unchanged, but the last
// assistant message's content grew (StreamToken). Re-render only
// that message and splice its lines back into the cache — this
// keeps updates O(new content), not O(whole history) per token.
// Without this branch, streamed tokens would never appear (the
// cache is only rebuilt on new-message or resize paths).
let cur_len = last_msg_len(state);
if cur_len != state.cached_last_len {
if let Some(last_msg) = state.transcript_cache.messages.back() {
if state.cached_last_start <= state.display_lines_cache.len() {
let new_lines = render_one_message(last_msg, content_width);
state.display_lines_cache.truncate(state.cached_last_start);
state.display_lines_cache.extend(new_lines);
}
}
state.cached_last_len = cur_len;
}
state.transcript_cache.dirty = false;
}
// Lazily recompute token count — hanya saat ada pesan baru
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;
}
}
fn draw(&mut self, 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);
let cache = &state.display_lines_cache;
// Hitung window tanpa clone
let total = cache.len();
let spinner_extra = usize::from(state.turn_in_flight());
let total_with_spinner = total + spinner_extra;
let max_offset = total_with_spinner.saturating_sub(max_visible);
let offset = scroll_offset.min(max_offset);
let end_idx = total_with_spinner.saturating_sub(offset);
let start_idx = end_idx.saturating_sub(max_visible);
// Kumpulkan hanya baris yang visible — tidak clone semua
let mut visible: Vec<Line> = Vec::with_capacity(max_visible);
let cache_end = end_idx.min(total);
let cache_start = start_idx.min(cache_end);
if cache_start < cache_end {
visible.extend_from_slice(&cache[cache_start..cache_end]);
}
// Spinner hanya ditambahkan jika visible window mencakup posisi terakhir
if state.turn_in_flight() && end_idx > total {
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx];
visible.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),
),
]));
}
let scroll_pct = if total_with_spinner > max_visible && max_offset > 0 {
((offset as f64 / max_offset as f64) * 100.0) as u8
} else {
0
};
let title = if scroll_pct > 0 {
format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct)
} else 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 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
}
/// Byte length of the last message's content + reasoning. Used as a cheap
/// change detector for the streaming render branch — avoids re-rendering on
/// spinner-only ticks where no new token arrived.
fn last_msg_len(state: &crate::state::AppStateRest) -> usize {
state
.transcript_cache
.messages
.back()
.map(|m| m.content.len() + m.reasoning.len())
.unwrap_or(0)
}