返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / streaming / mod.rs
1 //! Provider-delta ingest for display-clock rendering.
2 //!
3 //! Provider deltas arrive as dozens of tiny SSE chunks. This module buffers
4 //! them without mutating the visible transcript ([`StreamBuffer`]) and bounds
5 //! how often that buffer is flushed ([`StreamDisplayClock`]), so a burst of
6 //! deltas becomes one history mutation per display beat.
7 //!
8 //! Deltas are *input*, never animation timing. A commit beat flushes
9 //! everything received since the previous beat: there is no per-grapheme
10 //! typewriter and no adaptive drain policy. A two-gear "adaptive chunking"
11 //! policy lived here until v0.9.4; it could never emit anything other than
12 //! "drain everything available", so it was deleted rather than wired up.
13 //!
14 //! Newline-boundary safety (never showing a half-written code fence) is owned
15 //! by the incremental markdown parser downstream — see
16 //! `ParseState::commit_complete_lines` in `tui/markdown_render.rs`, which
17 //! leaves the trailing partial line uncommitted and re-parses it each tick.
18 //! A separate `LineBuffer` gate used to sit here, but both constructors
19 //! bypassed it, so it protected nothing and was removed with the policy.
20
21 use std::time::Duration;
22 use std::time::Instant;
23
24 /// Default cadence for moving queued provider deltas into visible transcript
25 /// text. This intentionally tracks animation frames rather than upstream SSE
26 /// cadence, so tiny bursty deltas coalesce into one history/cache mutation.
27 ///
28 /// ~60 FPS (16ms). Full motion may catch up sooner when backlog crosses
29 /// [`CATCH_UP_QUEUE_DEPTH`] / [`CATCH_UP_OLDEST_AGE`]; reduced motion never
30 /// accelerates — it stays on this steady clock (not a slow typewriter).
31 pub const DEFAULT_STREAM_COMMIT_INTERVAL: Duration = Duration::from_millis(16);
32
33 /// Queue-depth threshold that pulls the display clock forward (catch-up).
34 ///
35 /// Staged, not live: every production drain site calls
36 /// [`StreamDisplayClock::note_delta`] (queued = 1), so catch-up never fires
37 /// today. See the honesty note in `docs/MOTION_CONTRACT.md`.
38 pub const CATCH_UP_QUEUE_DEPTH: usize = 160;
39
40 /// Oldest-chunk age that pulls the display clock forward (catch-up).
41 /// Staged alongside [`CATCH_UP_QUEUE_DEPTH`].
42 pub const CATCH_UP_OLDEST_AGE: Duration = Duration::from_millis(1_200);
43
44 /// Frame-clock gate for stream display commits.
45 ///
46 /// Provider deltas may arrive in dozens of tiny chunks inside one event-loop
47 /// drain. This clock lets the TUI ingest those bytes cheaply, then mutate the
48 /// visible transcript at most once per display beat unless the stream is being
49 /// finalized or measured backlog demands catch-up.
50 #[derive(Debug, Clone)]
51 pub struct StreamDisplayClock {
52 interval: Duration,
53 pending: bool,
54 next_due_at: Option<Instant>,
55 last_commit_at: Option<Instant>,
56 commit_count: u64,
57 catch_up_count: u64,
58 /// When true, backlog may pull `next_due_at` forward to `now`.
59 allow_catch_up: bool,
60 }
61
62 impl Default for StreamDisplayClock {
63 fn default() -> Self {
64 Self::new(DEFAULT_STREAM_COMMIT_INTERVAL)
65 }
66 }
67
68 impl StreamDisplayClock {
69 pub fn new(interval: Duration) -> Self {
70 Self {
71 interval,
72 pending: false,
73 next_due_at: None,
74 last_commit_at: None,
75 commit_count: 0,
76 catch_up_count: 0,
77 allow_catch_up: true,
78 }
79 }
80
81 /// Enable/disable catch-up acceleration. Reduced motion keeps the steady
82 /// clock and never pulls beats forward.
83 pub fn set_allow_catch_up(&mut self, allow: bool) {
84 self.allow_catch_up = allow;
85 }
86
87 /// Note that at least one stream delta is waiting to become visible.
88 pub fn note_delta(&mut self, now: Instant) {
89 self.note_delta_with_backlog(now, 1, None);
90 }
91
92 /// Note a delta together with measured queue pressure.
93 ///
94 /// Normal motion coalesces onto the steady interval unless backlog crosses
95 /// the catch-up thresholds, in which case the next beat is due immediately.
96 pub fn note_delta_with_backlog(
97 &mut self,
98 now: Instant,
99 queued: usize,
100 oldest_age: Option<Duration>,
101 ) {
102 self.pending = true;
103 let catch_up = self.allow_catch_up
104 && (queued >= CATCH_UP_QUEUE_DEPTH
105 || oldest_age.is_some_and(|age| age >= CATCH_UP_OLDEST_AGE));
106
107 if catch_up {
108 self.next_due_at = Some(now);
109 self.catch_up_count = self.catch_up_count.saturating_add(1);
110 return;
111 }
112
113 if self.next_due_at.is_some() {
114 return;
115 }
116 self.next_due_at = Some(match self.last_commit_at {
117 Some(last) => last.checked_add(self.interval).unwrap_or(now).max(now),
118 None => now,
119 });
120 }
121
122 /// Returns the time until the pending commit is due, if any.
123 pub fn due_in(&self, now: Instant) -> Option<Duration> {
124 let due = self.next_due_at?;
125 Some(due.saturating_duration_since(now))
126 }
127
128 /// Consume a due commit beat.
129 pub fn take_due(&mut self, now: Instant) -> bool {
130 if !self.pending {
131 self.next_due_at = None;
132 return false;
133 }
134 let Some(due) = self.next_due_at else {
135 return false;
136 };
137 if now < due {
138 return false;
139 }
140 self.pending = false;
141 self.next_due_at = None;
142 self.last_commit_at = Some(now);
143 self.commit_count = self.commit_count.saturating_add(1);
144 true
145 }
146
147 /// Force a commit beat, used when the stream is being finalized.
148 pub fn flush_now(&mut self, now: Instant) -> bool {
149 let had_pending = self.pending;
150 self.pending = false;
151 self.next_due_at = None;
152 if had_pending {
153 self.last_commit_at = Some(now);
154 self.commit_count = self.commit_count.saturating_add(1);
155 }
156 had_pending
157 }
158
159 pub fn reset(&mut self) {
160 self.pending = false;
161 self.next_due_at = None;
162 self.last_commit_at = None;
163 self.commit_count = 0;
164 self.catch_up_count = 0;
165 }
166
167 /// Number of commit beats consumed since the last reset (observability).
168 #[cfg(test)]
169 pub fn commit_count(&self) -> u64 {
170 self.commit_count
171 }
172
173 /// Number of beats pulled forward by backlog since the last reset
174 /// (observability; see the staged-catch-up note on
175 /// [`CATCH_UP_QUEUE_DEPTH`]).
176 #[cfg(test)]
177 pub fn catch_up_count(&self) -> u64 {
178 self.catch_up_count
179 }
180 }
181
182 /// Buffers raw provider deltas between display-clock beats.
183 ///
184 /// One buffer per active block (assistant / thinking). Tool output is
185 /// unbuffered and bypasses this path entirely. A commit beat takes everything
186 /// received since the previous beat, so the visible text follows the upstream
187 /// delta cadence and the clock only bounds how often the transcript is
188 /// mutated.
189 #[derive(Debug, Default, Clone)]
190 pub struct StreamBuffer {
191 pending: String,
192 }
193
194 impl StreamBuffer {
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 /// Append a raw model delta.
200 pub fn push_delta(&mut self, delta: &str) {
201 self.pending.push_str(delta);
202 }
203
204 /// Whether any text is waiting for the next commit beat.
205 pub fn has_pending(&self) -> bool {
206 !self.pending.is_empty()
207 }
208
209 /// Take everything buffered since the previous beat.
210 pub fn take(&mut self) -> String {
211 std::mem::take(&mut self.pending)
212 }
213 }
214
215 /// Per-block streaming substate.
216 ///
217 /// ```text
218 /// raw delta -> StreamBuffer.push_delta -> commit beat -> take -> transcript
219 /// ```
220 #[derive(Debug, Default)]
221 struct BlockState {
222 /// Thinking blocks route to `accumulated_thinking`; text blocks to
223 /// `accumulated_text`.
224 is_thinking: bool,
225 /// Cleared once the block has been finalized.
226 is_streaming: bool,
227 /// Deltas received but not yet flushed to the transcript.
228 buffer: StreamBuffer,
229 }
230
231 /// State for managing multiple stream buffers (one per content block)
232 #[derive(Debug, Default)]
233 pub struct StreamingState {
234 /// Per-block state by index.
235 blocks: Vec<Option<BlockState>>,
236 /// Whether any stream is currently active
237 pub is_active: bool,
238 /// Accumulated text for display
239 pub accumulated_text: String,
240 /// Accumulated thinking for display
241 pub accumulated_thinking: String,
242 }
243
244 impl StreamingState {
245 /// Create a new streaming state
246 pub fn new() -> Self {
247 Self::default()
248 }
249
250 /// Start a new text block. Assistant prose is buffered until the next
251 /// display-clock beat so provider bursts produce one visible mutation.
252 pub fn start_text(&mut self, index: usize) {
253 self.start_block(index, false);
254 }
255
256 /// Start a new thinking block. Thinking deltas are buffered exactly like
257 /// assistant prose — long reasoning often arrives as one paragraph with no
258 /// intermediate newlines, so nothing here waits on a line boundary.
259 pub fn start_thinking(&mut self, index: usize) {
260 self.start_block(index, true);
261 }
262
263 fn start_block(&mut self, index: usize, is_thinking: bool) {
264 self.ensure_capacity(index);
265 self.blocks[index] = Some(BlockState {
266 is_thinking,
267 is_streaming: true,
268 buffer: StreamBuffer::new(),
269 });
270 self.is_active = true;
271 }
272
273 /// Push content to a block.
274 ///
275 /// `accumulated_text` / `accumulated_thinking` always track the full raw
276 /// stream so callers building API messages or doing retries see exactly
277 /// what the model emitted, regardless of UI pacing.
278 pub fn push_content(&mut self, index: usize, content: &str) {
279 if let Some(Some(block)) = self.blocks.get_mut(index) {
280 if block.is_thinking {
281 self.accumulated_thinking.push_str(content);
282 } else {
283 self.accumulated_text.push_str(content);
284 }
285 block.buffer.push_delta(content);
286 }
287 }
288
289 /// Run one commit beat and return the text to flush to the transcript.
290 /// Empty when nothing arrived since the previous beat.
291 pub fn commit_text(&mut self, index: usize) -> String {
292 match self.blocks.get_mut(index) {
293 Some(Some(block)) => block.buffer.take(),
294 _ => String::new(),
295 }
296 }
297
298 /// Whether a block holds text waiting to be flushed by the next commit
299 /// beat. Callers use this to keep the display clock ticking while a
300 /// buffer drains.
301 pub fn has_pending_stream_text(&self, index: usize) -> bool {
302 self.blocks
303 .get(index)
304 .and_then(|b| b.as_ref())
305 .is_some_and(|b| b.buffer.has_pending())
306 }
307
308 /// Finalize a block and return whatever text it still holds.
309 pub fn finalize_block_text(&mut self, index: usize) -> String {
310 let out = match self.blocks.get_mut(index) {
311 Some(Some(block)) => {
312 block.is_streaming = false;
313 block.buffer.take()
314 }
315 _ => return String::new(),
316 };
317 self.check_active();
318 out
319 }
320
321 /// Check if any stream is still active
322 fn check_active(&mut self) {
323 self.is_active = self.blocks.iter().flatten().any(|b| b.is_streaming);
324 }
325
326 /// Ensure capacity for the given index
327 fn ensure_capacity(&mut self, index: usize) {
328 while self.blocks.len() <= index {
329 self.blocks.push(None);
330 }
331 }
332
333 /// Reset the streaming state
334 pub fn reset(&mut self) {
335 self.blocks.clear();
336 self.is_active = false;
337 self.accumulated_text.clear();
338 self.accumulated_thinking.clear();
339 }
340 }
341
342 #[cfg(test)]
343 mod tests {
344 use super::*;
345
346 #[test]
347 fn assistant_text_streams_before_newline() {
348 let mut state = StreamingState::new();
349 state.start_text(0);
350 state.push_content(0, "hello world");
351
352 assert_eq!(state.commit_text(0), "hello world");
353 assert!(!state.has_pending_stream_text(0));
354 }
355
356 #[test]
357 fn thinking_text_streams_before_newline() {
358 let mut state = StreamingState::new();
359 state.start_thinking(0);
360 state.push_content(0, "thinking deeply");
361
362 assert_eq!(state.commit_text(0), "thinking deeply");
363 assert!(!state.has_pending_stream_text(0));
364 }
365
366 #[test]
367 fn commit_beat_drains_everything_received_since_the_previous_beat() {
368 // A burst arriving "at once" is displayed at the same cadence instead
369 // of being synthetically dripped and flushed at end of turn.
370 let mut state = StreamingState::new();
371 state.start_text(0);
372
373 let burst = "abcdefghijklmnopqrstuvwxyz".repeat(8);
374 state.push_content(0, &burst);
375 assert_eq!(state.commit_text(0), burst);
376 // Second beat with nothing new is empty, not a replay.
377 assert_eq!(state.commit_text(0), "");
378 }
379
380 #[test]
381 fn combining_marks_stay_with_their_base_letter() {
382 let mut state = StreamingState::new();
383 state.start_text(0);
384 state.push_content(0, "e\u{301}x");
385 assert_eq!(state.commit_text(0), "e\u{301}x");
386 }
387
388 #[test]
389 fn finalize_drains_partial_tail() {
390 // The final, possibly-unterminated line must survive finalization.
391 let mut state = StreamingState::new();
392 state.start_text(0);
393 state.push_content(0, "done\nno-newline-here");
394 assert_eq!(state.finalize_block_text(0), "done\nno-newline-here");
395 assert!(!state.is_active);
396 }
397
398 #[test]
399 fn finalize_after_commit_has_nothing_left() {
400 let mut state = StreamingState::new();
401 state.start_text(0);
402 state.push_content(0, "abc");
403 assert_eq!(state.commit_text(0), "abc");
404 assert_eq!(state.finalize_block_text(0), "");
405 }
406
407 #[test]
408 fn accumulators_track_raw_stream_by_block_kind() {
409 let mut state = StreamingState::new();
410 state.start_thinking(0);
411 state.push_content(0, "reasoning");
412 state.start_text(1);
413 state.push_content(1, "answer");
414
415 assert_eq!(state.accumulated_thinking, "reasoning");
416 assert_eq!(state.accumulated_text, "answer");
417 }
418
419 #[test]
420 fn bursty_stream_state_has_no_text_loss_after_coalesced_flushes() {
421 let mut state = StreamingState::new();
422 state.start_text(0);
423 let mut expected = String::new();
424
425 for idx in 0..250 {
426 let chunk = format!("{idx}.");
427 expected.push_str(&chunk);
428 state.push_content(0, &chunk);
429 }
430
431 let first_flush = state.commit_text(0);
432 assert_eq!(first_flush, expected);
433 assert_eq!(state.finalize_block_text(0), "");
434 }
435
436 #[test]
437 fn stream_display_clock_coalesces_bursty_tiny_deltas() {
438 let interval = Duration::from_millis(33);
439 let mut clock = StreamDisplayClock::new(interval);
440 let t0 = Instant::now();
441
442 for _ in 0..100 {
443 clock.note_delta(t0);
444 }
445
446 assert_eq!(clock.due_in(t0), Some(Duration::ZERO));
447 assert!(clock.take_due(t0));
448 assert_eq!(clock.commit_count(), 1);
449
450 for _ in 0..25 {
451 clock.note_delta(t0 + Duration::from_millis(5));
452 }
453 assert!(!clock.take_due(t0 + Duration::from_millis(5)));
454 assert_eq!(
455 clock.due_in(t0 + Duration::from_millis(5)),
456 Some(Duration::from_millis(28))
457 );
458 assert!(clock.take_due(t0 + interval));
459 assert_eq!(clock.commit_count(), 2);
460 }
461
462 #[test]
463 fn stream_display_clock_bounds_long_reasoning_commit_count() {
464 let interval = Duration::from_millis(33);
465 let mut clock = StreamDisplayClock::new(interval);
466 let t0 = Instant::now();
467 let mut commits = 0u64;
468
469 for millis in 0..300 {
470 let now = t0 + Duration::from_millis(millis);
471 clock.note_delta(now);
472 if clock.take_due(now) {
473 commits += 1;
474 }
475 }
476
477 assert!(commits > 1, "long streams should keep advancing visibly");
478 assert!(
479 commits <= 11,
480 "300 one-ms deltas should not commit on provider cadence: {commits}"
481 );
482 assert_eq!(commits, clock.commit_count());
483 }
484
485 #[test]
486 fn stream_display_clock_final_flush_consumes_pending_delta() {
487 let mut clock = StreamDisplayClock::new(Duration::from_millis(33));
488 let t0 = Instant::now();
489
490 clock.note_delta(t0);
491 assert!(clock.take_due(t0));
492 clock.note_delta(t0 + Duration::from_millis(4));
493
494 assert!(!clock.take_due(t0 + Duration::from_millis(4)));
495 assert!(clock.flush_now(t0 + Duration::from_millis(5)));
496 assert_eq!(clock.due_in(t0 + Duration::from_millis(5)), None);
497 assert!(!clock.take_due(t0 + Duration::from_millis(33)));
498 assert_eq!(clock.commit_count(), 2);
499 }
500
501 #[test]
502 fn normal_clock_catch_up_only_when_backlog_crosses_threshold() {
503 let interval = Duration::from_millis(33);
504 let mut clock = StreamDisplayClock::new(interval);
505 let t0 = Instant::now();
506
507 clock.note_delta(t0);
508 assert!(clock.take_due(t0));
509 // Small backlog after a commit stays on the steady interval.
510 clock.note_delta_with_backlog(
511 t0 + Duration::from_millis(1),
512 3,
513 Some(Duration::from_millis(5)),
514 );
515 assert!(!clock.take_due(t0 + Duration::from_millis(1)));
516 assert_eq!(clock.catch_up_count(), 0);
517
518 // Measured backlog crosses the catch-up threshold → due immediately.
519 clock.note_delta_with_backlog(
520 t0 + Duration::from_millis(2),
521 CATCH_UP_QUEUE_DEPTH,
522 Some(Duration::from_millis(10)),
523 );
524 assert!(clock.take_due(t0 + Duration::from_millis(2)));
525 assert!(clock.catch_up_count() >= 1);
526 }
527
528 #[test]
529 fn reduced_motion_keeps_steady_clock_without_catch_up_or_typewriter() {
530 let interval = Duration::from_millis(33);
531 let mut clock = StreamDisplayClock::new(interval);
532 clock.set_allow_catch_up(false);
533 let t0 = Instant::now();
534
535 clock.note_delta(t0);
536 assert!(clock.take_due(t0));
537 clock.note_delta_with_backlog(
538 t0 + Duration::from_millis(1),
539 CATCH_UP_QUEUE_DEPTH * 2,
540 Some(CATCH_UP_OLDEST_AGE),
541 );
542 // Reduced motion must not pull the beat forward.
543 assert!(!clock.take_due(t0 + Duration::from_millis(1)));
544 assert_eq!(clock.catch_up_count(), 0);
545 assert_eq!(
546 clock.due_in(t0 + Duration::from_millis(1)),
547 Some(Duration::from_millis(32))
548 );
549 assert!(clock.take_due(t0 + interval));
550 // Same interval as full motion — not a slower artificial typewriter.
551 assert_eq!(clock.commit_count(), 2);
552 }
553 }
554
554 lines RUST