返回 DeepSeek-TUI-2026
commit_tick.rs
根目录 / crates / tui / src / tui / streaming / commit_tick.rs
1 //! Commit-tick scheduler that drains a stream chunker according to policy.
2 //!
3 //! Bridges [`AdaptiveChunkingPolicy`] with a concrete [`StreamChunker`] queue.
4 //! Callers feed raw text deltas via [`StreamChunker::push_delta`], then call
5 //! [`run_commit_tick`] on every commit beat to obtain the next small text
6 //! slice to flush to the transcript on this beat.
7 //!
8 //! The chunker is the unit of streaming — one per active block (assistant /
9 //! thinking). Tool output is unbuffered and bypasses this path.
10
11 use std::collections::VecDeque;
12 use std::time::Duration;
13 use std::time::Instant;
14
15 use unicode_segmentation::UnicodeSegmentation;
16
17 use super::chunking::AdaptiveChunkingPolicy;
18 use super::chunking::ChunkingDecision;
19 use super::chunking::DrainPlan;
20 use super::chunking::QueueSnapshot;
21
22 const GRAPHEMES_PER_MICRO_CHUNK: usize = 1;
23 const CATCH_UP_MAX_MICRO_CHUNKS: usize = 12;
24
25 /// Buffers raw stream deltas and emits committed text in small display chunks.
26 #[derive(Debug, Default)]
27 pub struct StreamChunker {
28 /// Bytes received but not yet split into display chunks. Normally empty;
29 /// retained so `drain_remaining` has a lossless place to pull from if we
30 /// ever decide to hold a tail for a future markdown-sensitive mode.
31 pending: String,
32 /// Small grapheme-aligned chunks waiting to be flushed to the transcript.
33 queue: VecDeque<QueuedChunk>,
34 }
35
36 #[derive(Debug, Clone)]
37 struct QueuedChunk {
38 text: String,
39 enqueued_at: Instant,
40 }
41
42 impl StreamChunker {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 /// Append a raw model delta. Returns whether at least one new display chunk was queued.
48 pub fn push_delta(&mut self, delta: &str) -> bool {
49 if delta.is_empty() {
50 return false;
51 }
52 self.pending.push_str(delta);
53
54 let now = Instant::now();
55 let committed = std::mem::take(&mut self.pending);
56 let mut produced = false;
57 for chunk in split_into_micro_chunks(&committed) {
58 if chunk.is_empty() {
59 continue;
60 }
61 self.queue.push_back(QueuedChunk {
62 text: chunk,
63 enqueued_at: now,
64 });
65 produced = true;
66 }
67 produced
68 }
69
70 /// Number of display chunks currently queued for commit.
71 pub fn queued_lines(&self) -> usize {
72 self.queue.len()
73 }
74
75 /// Age of the oldest queued chunk, if any.
76 pub fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
77 self.queue
78 .front()
79 .map(|q| now.saturating_duration_since(q.enqueued_at))
80 }
81
82 /// Whether the queue is empty AND no buffered partial line remains.
83 pub fn is_idle(&self) -> bool {
84 self.queue.is_empty() && self.pending.is_empty()
85 }
86
87 /// Snapshot for policy decisions.
88 pub fn snapshot(&self, now: Instant) -> QueueSnapshot {
89 QueueSnapshot {
90 queued_lines: self.queue.len(),
91 oldest_age: self.oldest_queued_age(now),
92 }
93 }
94
95 /// Drain `max_lines` queued chunks and return them as concatenated text.
96 pub fn drain_lines(&mut self, max_lines: usize) -> String {
97 let n = max_lines.min(self.queue.len());
98 let mut out = String::new();
99 for queued in self.queue.drain(..n) {
100 out.push_str(&queued.text);
101 }
102 out
103 }
104
105 /// Drain any remaining pending bytes (called at stream finalize).
106 /// This includes both queued complete lines AND the tail partial line.
107 pub fn drain_remaining(&mut self) -> String {
108 let mut out = String::new();
109 while let Some(q) = self.queue.pop_front() {
110 out.push_str(&q.text);
111 }
112 if !self.pending.is_empty() {
113 out.push_str(&self.pending);
114 self.pending.clear();
115 }
116 out
117 }
118
119 /// Reset internal state.
120 pub fn reset(&mut self) {
121 self.pending.clear();
122 self.queue.clear();
123 }
124 }
125
126 /// One commit-tick decision plus the text that should be flushed on this tick.
127 pub struct CommitTickOutput {
128 pub committed_text: String,
129 pub decision: ChunkingDecision,
130 pub is_idle: bool,
131 }
132
133 /// Run a single commit tick: ask the policy, drain the chunker accordingly.
134 pub fn run_commit_tick(
135 policy: &mut AdaptiveChunkingPolicy,
136 chunker: &mut StreamChunker,
137 now: Instant,
138 ) -> CommitTickOutput {
139 let snapshot = chunker.snapshot(now);
140 let prior_mode = policy.mode();
141 let decision = policy.decide(snapshot, now);
142
143 if decision.mode != prior_mode {
144 tracing::trace!(
145 prior_mode = ?prior_mode,
146 new_mode = ?decision.mode,
147 queued_lines = snapshot.queued_lines,
148 oldest_queued_age_ms = snapshot.oldest_age.map(|age| age.as_millis() as u64),
149 entered_catch_up = decision.entered_catch_up,
150 "stream chunking mode transition"
151 );
152 }
153
154 let max = match decision.drain_plan {
155 DrainPlan::Single => 1,
156 DrainPlan::Batch(n) => n.min(CATCH_UP_MAX_MICRO_CHUNKS),
157 };
158
159 // Drain through the chunker; an empty queue under Smooth produces "".
160 let committed_text = chunker.drain_lines(max);
161
162 CommitTickOutput {
163 committed_text,
164 decision,
165 is_idle: chunker.is_idle(),
166 }
167 }
168
169 /// Split text into grapheme-aligned chunks. Newlines force a boundary so
170 /// markdown layout still settles quickly, but prose no longer waits for a full
171 /// line before becoming visible.
172 fn split_into_micro_chunks(text: &str) -> Vec<String> {
173 let mut out = Vec::new();
174 let mut current = String::new();
175 let mut graphemes = 0usize;
176
177 for grapheme in UnicodeSegmentation::graphemes(text, true) {
178 current.push_str(grapheme);
179 graphemes += 1;
180
181 if grapheme == "\n" || graphemes >= GRAPHEMES_PER_MICRO_CHUNK {
182 out.push(std::mem::take(&mut current));
183 graphemes = 0;
184 }
185 }
186
187 if !current.is_empty() {
188 out.push(current);
189 }
190
191 out
192 }
193
194 #[cfg(test)]
195 mod tests {
196 use super::*;
197 use crate::tui::streaming::chunking::ChunkingMode;
198
199 #[test]
200 fn prose_streams_before_newline() {
201 let mut chunker = StreamChunker::new();
202 let mut policy = AdaptiveChunkingPolicy::new();
203 let now = Instant::now();
204
205 chunker.push_delta("hello world");
206 let out = run_commit_tick(&mut policy, &mut chunker, now);
207 assert_eq!(out.committed_text, "h");
208 assert!(!chunker.is_idle(), "remaining prose should keep dripping");
209
210 let out = run_commit_tick(&mut policy, &mut chunker, now + Duration::from_millis(5));
211 assert_eq!(out.committed_text, "e");
212 }
213
214 #[test]
215 fn smooth_burst_emits_one_micro_chunk_per_tick() {
216 let mut chunker = StreamChunker::new();
217 let mut policy = AdaptiveChunkingPolicy::new();
218 let t0 = Instant::now();
219
220 chunker.push_delta("abc");
221 // Each tick under Smooth pulls exactly one grapheme.
222 let out1 = run_commit_tick(&mut policy, &mut chunker, t0);
223 assert_eq!(out1.decision.mode, ChunkingMode::Smooth);
224 assert_eq!(out1.committed_text, "a");
225 let out2 = run_commit_tick(&mut policy, &mut chunker, t0 + Duration::from_millis(20));
226 assert_eq!(out2.committed_text, "b");
227 let out3 = run_commit_tick(&mut policy, &mut chunker, t0 + Duration::from_millis(40));
228 assert_eq!(out3.committed_text, "c");
229 assert!(out3.is_idle);
230 }
231
232 #[test]
233 fn smooth_stream_keeps_combining_marks_with_base_letter() {
234 let mut chunker = StreamChunker::new();
235 let mut policy = AdaptiveChunkingPolicy::new();
236 let t0 = Instant::now();
237
238 chunker.push_delta("e\u{301}x");
239 let out1 = run_commit_tick(&mut policy, &mut chunker, t0);
240 assert_eq!(out1.committed_text, "e\u{301}");
241 let out2 = run_commit_tick(&mut policy, &mut chunker, t0 + Duration::from_millis(20));
242 assert_eq!(out2.committed_text, "x");
243 }
244
245 #[test]
246 fn large_burst_drains_in_catch_up_without_full_jump() {
247 // A large text burst arriving "at once" must trigger CatchUp on the first
248 // commit tick without dumping the full backlog in one jump.
249 let mut chunker = StreamChunker::new();
250 let mut policy = AdaptiveChunkingPolicy::new();
251 let now = Instant::now();
252
253 let burst = "abcdefghijklmnopqrstuvwxyz".repeat(8);
254 let expected_prefix: String = burst
255 .chars()
256 .take(CATCH_UP_MAX_MICRO_CHUNKS * GRAPHEMES_PER_MICRO_CHUNK)
257 .collect();
258 chunker.push_delta(&burst);
259 let out = run_commit_tick(&mut policy, &mut chunker, now);
260 assert_eq!(out.decision.mode, ChunkingMode::CatchUp);
261 assert_eq!(out.committed_text, expected_prefix);
262 assert!(!out.is_idle);
263 }
264
265 #[test]
266 fn finalize_drains_partial_tail() {
267 // The final, possibly-incomplete line must be flushed by drain_remaining.
268 let mut chunker = StreamChunker::new();
269 chunker.push_delta("done\nno-newline-here");
270 let drained = chunker.drain_remaining();
271 assert_eq!(drained, "done\nno-newline-here");
272 assert!(chunker.is_idle());
273 }
274 }
275
275 lines RUST