feat: add test_load and test_parse binaries for configuration loading and parsing
This commit is contained in:
@@ -1,15 +1,10 @@
|
||||
//! 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.
|
||||
//! 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 ratatui::layout::Rect;
|
||||
@@ -49,101 +44,122 @@ fn format_timestamp(ts: i64) -> String {
|
||||
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>,
|
||||
/// 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 display_lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut 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() {
|
||||
if msg.role == Role::Tool {
|
||||
let content = 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 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();
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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);
|
||||
display_lines.push(Line::from(spans));
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
display_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 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);
|
||||
lines.push(Line::from(spans));
|
||||
} else {
|
||||
lines.push(Line::from(header_prefix));
|
||||
}
|
||||
|
||||
for line in lines_iter {
|
||||
let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
|
||||
spans.extend(line.spans);
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Pre-render hook: rebuild display_lines_cache and token count if stale.
|
||||
/// Pre-render hook: update cache secara incremental.
|
||||
///
|
||||
/// 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`.
|
||||
/// 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) {
|
||||
// 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);
|
||||
let msg_count = state.transcript_cache.messages.len();
|
||||
let cached_count = state.cached_msg_count;
|
||||
|
||||
// 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);
|
||||
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.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 (expensive tiktoken call) only when new
|
||||
// messages have arrived — not on every render frame.
|
||||
// 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
|
||||
@@ -159,33 +175,40 @@ pub fn pre_render_chat(state: &mut crate::state::AppStateRest) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the scrollable chat transcript panel in tight inline-log style.
|
||||
/// Render chat transcript.
|
||||
///
|
||||
/// 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.
|
||||
/// 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;
|
||||
|
||||
// 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);
|
||||
// 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 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()
|
||||
};
|
||||
let end_idx = total_with_spinner.saturating_sub(offset);
|
||||
let start_idx = end_idx.saturating_sub(max_visible);
|
||||
|
||||
// Streaming indicator — appended live (not cached) so spinner animates smoothly.
|
||||
if state.turn_in_flight() {
|
||||
// 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];
|
||||
display_lines.push(Line::from(vec![
|
||||
visible.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} ", format_role_label(&Role::Assistant)),
|
||||
Style::default()
|
||||
@@ -202,11 +225,15 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe
|
||||
]));
|
||||
}
|
||||
|
||||
// Suppress unused variable warning — content_width used for rebuild path
|
||||
let _ = content_width;
|
||||
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
|
||||
};
|
||||
|
||||
// Build title
|
||||
let title = if messages.is_empty() {
|
||||
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())
|
||||
@@ -223,40 +250,6 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe
|
||||
.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));
|
||||
|
||||
Reference in New Issue
Block a user