Watch
1
0
Fork
You've already forked souveraine
0

feat(tui): semantic stream buffer for smooth token flow

Streamed tokens no longer append straight to the visible message.
They land in a stream_buffer; advance_tick releases a proportional
slice each frame (~1/3 of what is waiting, min 8 bytes), so a burst
of tokens reveals as steady flow rather than a sudden block. The cut
prefers a nearby whitespace break so no partial word flashes.

finalize_streaming flushes the buffer so a turn never ends with
unrevealed text. The 30 FPS redraw decoupling was already in place.
This commit is contained in:
Fimeg 2026-05-16 20:00:25 -04:00
commit 749a72b8a0

View file

@ -402,6 +402,10 @@ pub struct ChatState {
pub render_mode: ChatMode,
/// Live palette derived from the agent's current atmosphere.
pub palette: ChatPalette,
/// Semantic stream buffer. Tokens land here on arrival; `advance_tick`
/// releases a bounded slice per frame into the visible message, so a
/// burst of tokens reveals as steady flow instead of a sudden block.
pub stream_buffer: String,
}
/// Ephemeral /btw fork state. Mirrors Letta's BtwPane — a forked conversation
@ -491,6 +495,7 @@ impl ChatState {
show_esc_overlay: false,
render_mode: ChatMode::Conversation,
palette: ChatPalette::default(),
stream_buffer: String::new(),
})
}
@ -1023,7 +1028,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
match ev {
BackendEvent::Token(t) => {
self.phase = TurnPhase::Streaming;
self.append_streaming(&t);
// Buffer — `release_stream` (on tick) reveals it smoothly.
self.stream_buffer.push_str(&t);
}
BackendEvent::Reasoning(r) => {
self.thinking.push(r.clone());
@ -1456,6 +1462,45 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
/// Bump the animation tick. Called once per UI frame.
pub fn advance_tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
self.release_stream();
}
/// Release a bounded slice of the stream buffer into the visible message.
/// Proportional drain: a trickle empties in a frame or two (feels
/// instant), a burst drains over several frames (reads as flow). The cut
/// lands on a char boundary, and prefers a nearby whitespace break so a
/// partial word never flashes on screen.
fn release_stream(&mut self) {
if self.stream_buffer.is_empty() {
return;
}
let total = self.stream_buffer.len();
// Drain ~a third of what's waiting each frame (min 8 bytes) — fast
// enough never to lag behind arrival, slow enough to smooth bursts.
let mut take = (total / 3).max(8).min(total);
if take < total {
// Prefer a whitespace break within reach of the cut point.
if let Some(ws) = self.stream_buffer[..take].rfind(char::is_whitespace) {
if take - ws <= 24 {
take = ws + 1;
}
}
// Snap forward to a valid UTF-8 boundary.
while take < total && !self.stream_buffer.is_char_boundary(take) {
take += 1;
}
}
let chunk: String = self.stream_buffer.drain(..take).collect();
self.append_streaming(&chunk);
}
/// Empty the stream buffer immediately into the visible message. Called
/// when a turn finalizes so no buffered text is left unrevealed.
fn flush_stream(&mut self) {
if !self.stream_buffer.is_empty() {
let rest = std::mem::take(&mut self.stream_buffer);
self.append_streaming(&rest);
}
}
fn append_streaming(&mut self, t: &str) {
@ -1474,6 +1519,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
}
fn finalize_streaming(&mut self) {
// Reveal anything still buffered so the final message is complete.
self.flush_stream();
for msg in self.messages.iter_mut().rev() {
if let ChatMessage::Assistant { streaming, .. } = msg {
if *streaming {