//! Chat transcript panel rendering — tight inline log style. //! //! Performance design: //! - `display_lines_cache` menyimpan Vec 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 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}") } /// Render satu pesan menjadi Vec>. /// Dipanggil incremental — hanya untuk pesan baru, bukan seluruh history. fn render_one_message( msg: &crate::state::ChatMessageDisplay, content_width: u16, ) -> Vec> { let mut lines: Vec> = 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 = 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 } /// Pre-render hook: update cache secara incremental. /// /// Strategi: /// - Cache menyimpan jumlah pesan saat terakhir di-render (`cached_msg_count`) /// - Jika pesan bertambah → hanya render pesan BARU, append ke cache /// - Jika pesan berkurang (eviction) atau width berubah → full rebuild /// - Token count dihitung lazily hanya jika `token_count_dirty` pub fn pre_render_chat(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> = 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.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.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; } } /// Render chat transcript. /// /// TIDAK melakukan clone seluruh cache — hanya mengambil slice window /// yang visible (biasanya 30-50 baris) via reference langsung ke cache. 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); 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 = 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>) -> 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 }