|
|
|
@@ -5,6 +5,11 @@
|
|
|
|
|
//! `{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;
|
|
|
|
@@ -44,14 +49,15 @@ fn format_timestamp(ts: i64) -> String {
|
|
|
|
|
format!("{hrs:02}:{mins:02}")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render the scrollable chat transcript panel in tight inline-log style.
|
|
|
|
|
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 content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2);
|
|
|
|
|
|
|
|
|
|
let mut display_lines: Vec<Line> = Vec::new();
|
|
|
|
|
/// 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 {
|
|
|
|
@@ -115,7 +121,66 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Streaming indicator
|
|
|
|
|
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();
|
|
|
|
@@ -137,7 +202,10 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::state::AppStateRe
|
|
|
|
|
]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scrolling
|
|
|
|
|
// 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 {
|
|
|
|
|