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    ]));
65    let formatted = cached_format(&state.streaming_text, formatter);
66    for line in formatted {
67        let mut spans = vec![Span::styled("  ", Style::default().fg(Color::Cyan))];
68        spans.extend(line.spans);
69        lines.push(Line::from(spans));
70    }
71}
72
73fn cached_format(text: &str, formatter: &MessageFormatter) -> Vec<Line<'static>> {
74    STREAM_PARSE_CACHE.with(|cell| {
75        let cur_len = text.len();
76        if let Some((parsed_len, ref lines)) = *cell.borrow()
77            && cur_len >= parsed_len
78            && cur_len - parsed_len < STREAM_REPARSE_THRESHOLD
79        {
80            return lines.clone();
81        }
82        let formatted = formatter.format_content(text, "assistant");
83        *cell.borrow_mut() = Some((cur_len, formatted.clone()));
84        formatted
85    })
86}
87
88/// Reset the streaming parse cache. Call when a new assistant turn begins
89/// (e.g. `streaming_text` was cleared) so a shrunken buffer doesn't keep
90/// reusing stale parsed lines.
91pub fn reset_stream_parse_cache() {
92    STREAM_PARSE_CACHE.with(|cell| *cell.borrow_mut() = None);
93}