返回 DeepSeek-TUI-2026
transcript_cache.rs
根目录 / crates / tui / src / tui / transcript_cache.rs
1 //! Wrapped-line cache for the live transcript overlay (#94).
2 //!
3 //! Each cell's rendered output is cached under a `(CellId, width, revision)`
4 //! key. The revision portion comes from `App.history_revisions` (or the
5 //! synthetic active-cell revision); the cache invalidates entries the moment
6 //! a cell mutates because the upstream tag changes. Width changes invalidate
7 //! everything for that cell because wrap layout depends on width.
8 //!
9 //! Live cells (the streaming assistant body, in-flight tool entries) bump
10 //! their revision on every mutation, so the cache always reflects the latest
11 //! frame of their output without ever paying for a re-wrap of unrelated
12 //! cells. Resize-driven re-wrap is bounded to the cells whose width key just
13 //! changed; nothing else is invalidated.
14 //!
15 //! The cache is bounded to keep memory predictable on long sessions.
16 //! Eviction is a simple insertion-order scheme — a strict LRU would be
17 //! overkill for the access pattern (full sweep on every render frame).
18
19 use std::collections::HashMap;
20 use std::collections::VecDeque;
21
22 use ratatui::text::Line;
23
24 /// Soft cap on the number of cached entries before insertion-order eviction
25 /// kicks in. Sized for the worst-case "5,000-line transcript at 200 cells,
26 /// resize twice" pattern; well under a megabyte even with 10 KB cells.
27 const DEFAULT_CAPACITY: usize = 512;
28
29 /// Identifier for a transcript cell within a live render. `History(idx)`
30 /// addresses a finalized history cell at the given index;
31 /// `Active(entry_idx)` addresses the synthetic active-cell entry while a
32 /// turn is in flight.
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34 pub enum CellId {
35 History(usize),
36 Active(usize),
37 }
38
39 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40 struct Key {
41 cell: CellId,
42 width: u16,
43 revision: u64,
44 }
45
46 /// Bounded cache of wrapped lines. Keyed by `(cell_id, width, revision)` —
47 /// any change to a cell's revision (mutation), the terminal width (resize),
48 /// or the cell's identity (insert/delete shifting indices) misses the cache.
49 #[derive(Debug)]
50 pub struct TranscriptCache {
51 capacity: usize,
52 entries: HashMap<Key, Vec<Line<'static>>>,
53 /// Insertion order so we can evict the oldest entry when full. Two-step
54 /// (HashMap + VecDeque) so insertion is O(1) and lookup stays O(1).
55 insertion_order: VecDeque<Key>,
56 }
57
58 impl Default for TranscriptCache {
59 fn default() -> Self {
60 Self::with_capacity(DEFAULT_CAPACITY)
61 }
62 }
63
64 impl TranscriptCache {
65 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 #[must_use]
71 pub fn with_capacity(capacity: usize) -> Self {
72 Self {
73 capacity: capacity.max(1),
74 entries: HashMap::with_capacity(capacity.max(1)),
75 insertion_order: VecDeque::with_capacity(capacity.max(1)),
76 }
77 }
78
79 /// Look up wrapped lines previously rendered at this exact key. Returns
80 /// `None` if the cell never wrapped at this width/revision before.
81 #[must_use]
82 pub fn get(&self, cell: CellId, width: u16, revision: u64) -> Option<&[Line<'static>]> {
83 let key = Key {
84 cell,
85 width,
86 revision,
87 };
88 self.entries.get(&key).map(Vec::as_slice)
89 }
90
91 /// Cache a fresh wrap result. If the cache is at capacity the oldest
92 /// inserted entry is evicted first.
93 pub fn insert(&mut self, cell: CellId, width: u16, revision: u64, lines: Vec<Line<'static>>) {
94 let key = Key {
95 cell,
96 width,
97 revision,
98 };
99 // Replace an existing key in place — keep its position in the
100 // insertion-order queue so we don't trigger spurious eviction.
101 if self.entries.insert(key, lines).is_some() {
102 return;
103 }
104 if self.entries.len() > self.capacity
105 && let Some(oldest) = self.insertion_order.pop_front()
106 {
107 self.entries.remove(&oldest);
108 }
109 self.insertion_order.push_back(key);
110 }
111
112 /// Drop every cached entry. Used when the underlying transcript shape
113 /// changes drastically (e.g. session reset).
114 #[allow(dead_code)] // Reserved for /clear and session-reset call sites.
115 pub fn clear(&mut self) {
116 self.entries.clear();
117 self.insertion_order.clear();
118 }
119
120 #[cfg(test)]
121 pub fn len(&self) -> usize {
122 self.entries.len()
123 }
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129 use ratatui::text::Span;
130
131 fn line(s: &str) -> Line<'static> {
132 Line::from(Span::raw(s.to_string()))
133 }
134
135 #[test]
136 fn miss_returns_none() {
137 let cache = TranscriptCache::new();
138 assert!(cache.get(CellId::History(0), 80, 1).is_none());
139 }
140
141 #[test]
142 fn round_trip_returns_inserted_lines() {
143 let mut cache = TranscriptCache::new();
144 let lines = vec![line("hello"), line("world")];
145 cache.insert(CellId::History(0), 80, 1, lines.clone());
146 let got = cache
147 .get(CellId::History(0), 80, 1)
148 .expect("entry should be cached");
149 assert_eq!(got.len(), 2);
150 assert_eq!(got[0].spans[0].content, "hello");
151 }
152
153 #[test]
154 fn revision_bump_invalidates_cell() {
155 let mut cache = TranscriptCache::new();
156 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
157 // Hit at rev=1
158 assert!(cache.get(CellId::History(0), 80, 1).is_some());
159 // Miss at rev=2 — caller is expected to re-wrap and insert again.
160 assert!(cache.get(CellId::History(0), 80, 2).is_none());
161 }
162
163 #[test]
164 fn width_change_invalidates_cell() {
165 let mut cache = TranscriptCache::new();
166 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
167 assert!(cache.get(CellId::History(0), 80, 1).is_some());
168 assert!(cache.get(CellId::History(0), 100, 1).is_none());
169 }
170
171 #[test]
172 fn active_cells_are_distinct_from_history() {
173 let mut cache = TranscriptCache::new();
174 cache.insert(CellId::History(0), 80, 1, vec![line("history")]);
175 cache.insert(CellId::Active(0), 80, 1, vec![line("active")]);
176 assert_eq!(
177 cache.get(CellId::History(0), 80, 1).unwrap()[0].spans[0].content,
178 "history"
179 );
180 assert_eq!(
181 cache.get(CellId::Active(0), 80, 1).unwrap()[0].spans[0].content,
182 "active"
183 );
184 }
185
186 #[test]
187 fn reinsert_same_key_does_not_evict() {
188 // Capacity 2 — re-inserting an existing key must not cause the other
189 // entry to be evicted; otherwise re-rendering the same cell on every
190 // frame would churn unrelated entries out of the cache.
191 let mut cache = TranscriptCache::with_capacity(2);
192 cache.insert(CellId::History(0), 80, 1, vec![line("a")]);
193 cache.insert(CellId::History(1), 80, 1, vec![line("b")]);
194 cache.insert(CellId::History(0), 80, 1, vec![line("a-prime")]);
195 assert!(cache.get(CellId::History(1), 80, 1).is_some());
196 }
197
198 #[test]
199 fn capacity_evicts_oldest_on_overflow() {
200 let mut cache = TranscriptCache::with_capacity(2);
201 cache.insert(CellId::History(0), 80, 1, vec![line("a")]);
202 cache.insert(CellId::History(1), 80, 1, vec![line("b")]);
203 cache.insert(CellId::History(2), 80, 1, vec![line("c")]);
204 // Oldest (History(0)) should be gone; the two newer keys remain.
205 assert!(cache.get(CellId::History(0), 80, 1).is_none());
206 assert!(cache.get(CellId::History(1), 80, 1).is_some());
207 assert!(cache.get(CellId::History(2), 80, 1).is_some());
208 assert_eq!(cache.len(), 2);
209 }
210
211 #[test]
212 fn clear_drops_everything() {
213 let mut cache = TranscriptCache::new();
214 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
215 cache.clear();
216 assert!(cache.get(CellId::History(0), 80, 1).is_none());
217 assert_eq!(cache.len(), 0);
218 }
219 }
220
220 lines RUST