use std::collections::VecDeque;
use std::time::{Duration, Instant};
use synaps_cli::{LlmEvent, StreamEvent};
use super::super::app::App;
const BASE_CPS: f64 = 320.0;
const CATCH_UP: f64 = 0.12;
pub(crate) const MAX_LAG: Duration = Duration::from_millis(500);
const FIRST_STEP: Duration = Duration::from_millis(16);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
Thinking,
Text,
}
struct Chunk {
kind: Kind,
text: String,
at: Instant,
}
#[derive(Default)]
pub(crate) struct Reveal {
queue: VecDeque<Chunk>,
chars: usize,
last: Option<Instant>,
}
impl Reveal {
pub(crate) fn pending(&self) -> bool {
!self.queue.is_empty()
}
#[cfg_attr(not(any(test, feature = "testing")), allow(dead_code))]
pub(crate) fn backlog(&self) -> usize {
self.chars
}
pub(crate) fn push(&mut self, kind: Kind, text: &str, now: Instant) {
if text.is_empty() {
return;
}
self.chars += text.chars().count();
match self.queue.back_mut() {
Some(c) if c.kind == kind && c.at == now => c.text.push_str(text),
_ => self.queue.push_back(Chunk {
kind,
text: text.to_string(),
at: now,
}),
}
}
pub(crate) fn take(&mut self, now: Instant) -> Vec<(Kind, String)> {
let mut out: Vec<(Kind, String)> = Vec::new();
if self.queue.is_empty() {
self.last = None;
return out;
}
let step = match self.last {
Some(last) => now.saturating_duration_since(last),
None => FIRST_STEP,
};
let rate = BASE_CPS + self.chars as f64 / CATCH_UP;
let mut budget = (step.as_secs_f64() * rate).ceil() as usize;
self.last = Some(now);
while let Some(front) = self.queue.front_mut() {
let overdue = now.saturating_duration_since(front.at) >= MAX_LAG;
let n = front.text.chars().count();
let (kind, text) = if overdue || n <= budget {
budget = budget.saturating_sub(n);
self.chars -= n;
let c = self.queue.pop_front().expect("front exists");
(c.kind, c.text)
} else if budget > 0 {
let cut = front
.text
.char_indices()
.nth(budget)
.map_or(front.text.len(), |(i, _)| i);
let rest = front.text.split_off(cut);
let head = std::mem::replace(&mut front.text, rest);
self.chars -= budget;
budget = 0;
(front.kind, head)
} else {
break;
};
match out.last_mut() {
Some((k, s)) if *k == kind => s.push_str(&text),
_ => out.push((kind, text)),
}
}
if self.queue.is_empty() {
self.last = None;
}
out
}
pub(crate) fn drain(&mut self) -> Vec<(Kind, String)> {
let mut out: Vec<(Kind, String)> = Vec::new();
for c in self.queue.drain(..) {
match out.last_mut() {
Some((k, s)) if *k == c.kind => s.push_str(&c.text),
_ => out.push((c.kind, c.text)),
}
}
self.chars = 0;
self.last = None;
out
}
}
pub(crate) fn paced(event: &StreamEvent) -> Option<(Kind, &str)> {
match event {
StreamEvent::Llm(LlmEvent::Text(t)) => Some((Kind::Text, t.as_str())),
StreamEvent::Llm(LlmEvent::Thinking(t)) => Some((Kind::Thinking, t.as_str())),
_ => None,
}
}
pub(crate) fn input_flushes(event: &crossterm::event::Event) -> bool {
use crossterm::event::{Event, KeyCode, KeyModifiers, MouseEventKind};
match event {
Event::Key(k) => {
let editing = matches!(
k.code,
KeyCode::Char(_)
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Left
| KeyCode::Right
| KeyCode::Home
| KeyCode::End
| KeyCode::Tab
);
!(editing && (k.modifiers - KeyModifiers::SHIFT).is_empty())
}
Event::Mouse(m) => !matches!(m.kind, MouseEventKind::Moved),
Event::Paste(_) | Event::FocusGained | Event::FocusLost => false,
Event::Resize(..) => true,
}
}
impl App {
pub(crate) fn set_smooth_streaming(&mut self, on: bool) {
self.smooth_streaming = on;
if !on {
self.flush_reveal();
}
}
pub(crate) fn defer_stream_text(&mut self, event: &StreamEvent) -> bool {
if !self.smooth_streaming {
return false;
}
let Some((kind, text)) = paced(event).filter(|(_, t)| !t.is_empty()) else {
return false;
};
let now = self.clock.now();
self.reveal.push(kind, text, now);
self.needs_redraw = true;
true
}
pub(crate) fn advance_reveal(&mut self) {
if self.reveal.pending() {
let now = self.clock.now();
let due = self.reveal.take(now);
self.apply_revealed(due);
}
}
pub(crate) fn flush_reveal(&mut self) {
if self.reveal.pending() {
let all = self.reveal.drain();
self.apply_revealed(all);
}
}
fn apply_revealed(&mut self, runs: Vec<(Kind, String)>) {
if runs.is_empty() {
return;
}
for (kind, text) in runs {
match kind {
Kind::Text => self.transcript.append_or_update_text(&text),
Kind::Thinking => self.transcript.append_or_update_thinking(&text),
}
}
self.needs_redraw = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn joined(runs: &[(Kind, String)]) -> String {
runs.iter().map(|(_, s)| s.as_str()).collect()
}
#[test]
fn a_burst_is_paced_over_frames_and_catches_up() {
let t0 = Instant::now();
let mut r = Reveal::default();
let burst: String = (0..60).map(|i| format!("word{i:02} ab ")).collect();
assert_eq!(burst.len(), 600);
r.push(Kind::Text, &burst, t0);
r.push(Kind::Text, &burst, t0);
let mut shown = String::new();
let mut frames = 0;
let mut t = t0;
let first = r.take(t);
assert!(!first.is_empty(), "the first frame shows some of it");
assert!(joined(&first).len() < 300, "but not all: {}", joined(&first).len());
shown.push_str(&joined(&first));
while r.pending() {
t += Duration::from_millis(16);
frames += 1;
shown.push_str(&joined(&r.take(t)));
assert!(frames < 100, "never stalls");
}
assert_eq!(shown, format!("{burst}{burst}"), "whole, in order");
let took = t - t0;
assert!(frames >= 8, "several frames, not one: {frames}");
assert!(took <= Duration::from_millis(450), "caught up in {took:?}");
assert_eq!(r.backlog(), 0);
}
#[test]
fn steady_streaming_does_not_lag() {
let t0 = Instant::now();
let mut r = Reveal::default();
let mut t = t0;
for _ in 0..40 {
r.push(Kind::Text, "seventeen bytes. ", t);
for _ in 0..3 {
r.take(t);
t += Duration::from_millis(16);
}
t += Duration::from_millis(2);
assert_eq!(r.backlog(), 0, "shown within three frames");
}
r.take(t);
assert!(!r.pending(), "drained between deltas");
}
#[test]
fn nothing_waits_longer_than_the_bound() {
let t0 = Instant::now();
let mut r = Reveal::default();
r.push(Kind::Text, &"x".repeat(50_000), t0);
r.take(t0);
let out = r.take(t0 + MAX_LAG);
assert!(!r.pending(), "released at the bound");
assert!(joined(&out).len() > 40_000);
}
#[test]
fn order_kinds_and_char_boundaries() {
let t0 = Instant::now();
let mut r = Reveal::default();
r.push(Kind::Thinking, "ponder ", t0);
r.push(Kind::Thinking, "more", t0);
r.push(Kind::Text, &"é".repeat(400), t0);
let first = r.take(t0);
assert_eq!(first[0], (Kind::Thinking, "ponder more".to_string()));
assert_eq!(first[1].0, Kind::Text);
assert!(first[1].1.chars().all(|c| c == 'é'));
assert!(r.take(t0).is_empty(), "idempotent at a fixed instant");
let rest = r.drain();
assert_eq!(rest.len(), 1);
assert_eq!(first[1].1.chars().count() + rest[0].1.chars().count(), 400);
assert!(!r.pending() && r.backlog() == 0);
}
#[test]
fn input_that_flushes() {
use crossterm::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
};
let key = |c, m| Event::Key(KeyEvent::new(c, m));
assert!(!input_flushes(&key(KeyCode::Char('a'), KeyModifiers::NONE)));
assert!(!input_flushes(&key(KeyCode::Char('A'), KeyModifiers::SHIFT)));
assert!(!input_flushes(&key(KeyCode::Backspace, KeyModifiers::NONE)));
assert!(input_flushes(&key(KeyCode::Char('o'), KeyModifiers::CONTROL)));
assert!(input_flushes(&key(KeyCode::Esc, KeyModifiers::NONE)));
assert!(input_flushes(&key(KeyCode::Up, KeyModifiers::SHIFT)));
assert!(input_flushes(&key(KeyCode::PageUp, KeyModifiers::NONE)));
assert!(input_flushes(&key(KeyCode::Enter, KeyModifiers::NONE)));
let mouse = |kind| {
Event::Mouse(MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
})
};
assert!(input_flushes(&mouse(MouseEventKind::ScrollUp)));
assert!(!input_flushes(&mouse(MouseEventKind::Moved)));
}
}