Skip to main content

codetether_agent/tui/ui/chat_view/
streaming.rs

1//! In-flight streaming assistant preview.
2//!
3//! Renders partial text via full [`MessageFormatter`]. Uses a thread-local
4//! parse cache to avoid re-running the markdown formatter on every token:
5//! while the streaming text grows by fewer than [`STREAM_REPARSE_THRESHOLD`]
6//! bytes since the last parse, the cached lines are reused as-is.
7
8use std::cell::RefCell;
9
10use ratatui::{
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13};
14
15use crate::tui::app::state::AppState;
16use crate::tui::message_formatter::MessageFormatter;
17use crate::tui::ui::status_bar::format_timestamp;
18
19/// Reparse threshold: reuse the previously-parsed streaming preview while the
20/// text has grown by fewer than this many bytes.
21const STREAM_REPARSE_THRESHOLD: usize = 48;
22
23thread_local! {
24    /// `(streaming_text_len_at_parse, parsed_lines)`. Keyed per UI thread;
25    /// the TUI renders single-threaded so this avoids any locking cost.
26    static STREAM_PARSE_CACHE: RefCell<Option<(usize, Vec<Line<'static>>)>> =
27        const { RefCell::new(None) };
28}
29
30/// Append a streaming preview block when the app is actively receiving text.
31///
32/// # Examples
33///
34/// ```rust,no_run
35/// # use codetether_agent::tui::ui::chat_view::streaming::push_streaming_preview;
36/// # fn d(s:&codetether_agent::tui::app::state::AppState){ let f=codetether_agent::tui::message_formatter::MessageFormatter::new(76); let mut l:Vec<ratatui::text::Line>=vec![]; push_streaming_preview(&mut l,s,40,&f); }
37/// ```
38pub fn push_streaming_preview(
39    lines: &mut Vec<Line<'static>>,
40    state: &AppState,
41    separator_width: usize,
42    formatter: &MessageFormatter,
43) {
44    if !state.processing || state.streaming_text.is_empty() {
45        return;
46    }
47    lines.push(Line::from(Span::styled(
48        "─".repeat(separator_width.min(40)),
49        Style::default().fg(Color::DarkGray).dim(),
50    )));
51    lines.push(Line::from(vec![
52        Span::styled(
53            format!("[{}] ", format_timestamp(std::time::SystemTime::now())),
54            Style::default().fg(Color::DarkGray).dim(),
55        ),
56        Span::styled("◆ ", Style::default().fg(Color::Cyan).bold()),
57        Span::styled("assistant", Style::default().fg(Color::Cyan).bold()),
58        Span::styled(
59            " (streaming…)",
60            Style::default()
61                .fg(Color::DarkGray)
62                .add_modifier(Modifier::DIM),
63        ),
64        super::elapsed_badge::elapsed_badge(state),
65    ]));
66    let formatted = cached_format(&state.streaming_text, formatter);
67    for line in formatted {
68        let mut spans = vec![Span::styled("  ", Style::default().fg(Color::Cyan))];
69        spans.extend(line.spans);
70        lines.push(Line::from(spans));
71    }
72}
73
74fn cached_format(text: &str, formatter: &MessageFormatter) -> Vec<Line<'static>> {
75    STREAM_PARSE_CACHE.with(|cell| {
76        let cur_len = text.len();
77        if let Some((parsed_len, ref lines)) = *cell.borrow()
78            && cur_len >= parsed_len
79            && cur_len - parsed_len < STREAM_REPARSE_THRESHOLD
80        {
81            return lines.clone();
82        }
83        let formatted = formatter.format_content(text, "assistant");
84        *cell.borrow_mut() = Some((cur_len, formatted.clone()));
85        formatted
86    })
87}
88
89/// Reset the streaming parse cache. Call when a new assistant turn begins
90/// (e.g. `streaming_text` was cleared) so a shrunken buffer doesn't keep
91/// reusing stale parsed lines.
92pub fn reset_stream_parse_cache() {
93    STREAM_PARSE_CACHE.with(|cell| *cell.borrow_mut() = None);
94}