返回 CodeWhale
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 //! and the owner sizes the cap to the sweep with
19 //! [`TranscriptCache::ensure_capacity`] so a sweep never evicts itself.
20
21 use std::collections::HashMap;
22 use std::collections::VecDeque;
23
24 use ratatui::text::Line;
25
26 #[derive(Debug, Clone)]
27 pub(crate) struct CachedTranscriptLine {
28 pub line: Line<'static>,
29 pub links: Vec<crate::tui::osc8::LineLink>,
30 }
31
32 /// Soft cap on the number of cached entries before insertion-order eviction
33 /// kicks in. Sized for the worst-case "5,000-line transcript at 200 cells,
34 /// resize twice" pattern; well under a megabyte even with 10 KB cells.
35 const DEFAULT_CAPACITY: usize = 512;
36
37 /// Identifier for a transcript cell within a live render. `History(idx)`
38 /// addresses a finalized history cell at the given index;
39 /// `Active(entry_idx)` addresses the synthetic active-cell entry while a
40 /// turn is in flight.
41 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42 pub enum CellId {
43 History(usize),
44 Active(usize),
45 }
46
47 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48 struct Key {
49 cell: CellId,
50 width: u16,
51 revision: u64,
52 }
53
54 /// Bounded cache of wrapped lines. Keyed by `(cell_id, width, revision)` —
55 /// any change to a cell's revision (mutation), the terminal width (resize),
56 /// or the cell's identity (insert/delete shifting indices) misses the cache.
57 #[derive(Debug)]
58 pub struct TranscriptCache {
59 capacity: usize,
60 entries: HashMap<Key, Vec<CachedTranscriptLine>>,
61 /// Insertion order so we can evict the oldest entry when full. Two-step
62 /// (HashMap + VecDeque) so insertion is O(1) and lookup stays O(1).
63 insertion_order: VecDeque<Key>,
64 }
65
66 impl Default for TranscriptCache {
67 fn default() -> Self {
68 Self::with_capacity(DEFAULT_CAPACITY)
69 }
70 }
71
72 impl TranscriptCache {
73 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 #[must_use]
79 pub fn with_capacity(capacity: usize) -> Self {
80 Self {
81 capacity: capacity.max(1),
82 entries: HashMap::with_capacity(capacity.max(1)),
83 insertion_order: VecDeque::with_capacity(capacity.max(1)),
84 }
85 }
86
87 /// Grow the cap to at least `min_capacity`; never shrinks. The overlay
88 /// sweeps every cell each rebuild, and insertion-order eviction under a
89 /// cap smaller than that sweep evicts each entry before its next use —
90 /// every lookup misses. Sizing to the sweep keeps the scheme simple and
91 /// the hit rate whole.
92 pub fn ensure_capacity(&mut self, min_capacity: usize) {
93 self.capacity = self.capacity.max(min_capacity);
94 }
95
96 /// Look up wrapped lines previously rendered at this exact key. Returns
97 /// `None` if the cell never wrapped at this width/revision before.
98 #[must_use]
99 pub fn get(&self, cell: CellId, width: u16, revision: u64) -> Option<&[CachedTranscriptLine]> {
100 let key = Key {
101 cell,
102 width,
103 revision,
104 };
105 self.entries.get(&key).map(Vec::as_slice)
106 }
107
108 /// Cache a fresh wrap result. If the cache is at capacity the oldest
109 /// inserted entry is evicted first.
110 pub fn insert(
111 &mut self,
112 cell: CellId,
113 width: u16,
114 revision: u64,
115 lines: Vec<CachedTranscriptLine>,
116 ) {
117 let key = Key {
118 cell,
119 width,
120 revision,
121 };
122 // Replace an existing key in place — keep its position in the
123 // insertion-order queue so we don't trigger spurious eviction.
124 if self.entries.insert(key, lines).is_some() {
125 return;
126 }
127 if self.entries.len() > self.capacity
128 && let Some(oldest) = self.insertion_order.pop_front()
129 {
130 self.entries.remove(&oldest);
131 }
132 self.insertion_order.push_back(key);
133 }
134
135 /// Drop every cached entry. Used when the underlying transcript shape
136 /// changes drastically (e.g. session reset).
137 #[allow(dead_code)] // Reserved for /clear and session-reset call sites.
138 pub fn clear(&mut self) {
139 self.entries.clear();
140 self.insertion_order.clear();
141 }
142
143 #[cfg(test)]
144 pub fn len(&self) -> usize {
145 self.entries.len()
146 }
147 }
148
149 #[cfg(test)]
150 mod tests {
151 use super::*;
152 use ratatui::text::Span;
153
154 fn line(s: &str) -> CachedTranscriptLine {
155 CachedTranscriptLine {
156 line: Line::from(Span::raw(s.to_string())),
157 links: Vec::new(),
158 }
159 }
160
161 #[test]
162 fn miss_returns_none() {
163 let cache = TranscriptCache::new();
164 assert!(cache.get(CellId::History(0), 80, 1).is_none());
165 }
166
167 #[test]
168 fn round_trip_returns_inserted_lines() {
169 let mut cache = TranscriptCache::new();
170 let lines = vec![line("hello"), line("world")];
171 cache.insert(CellId::History(0), 80, 1, lines.clone());
172 let got = cache
173 .get(CellId::History(0), 80, 1)
174 .expect("entry should be cached");
175 assert_eq!(got.len(), 2);
176 assert_eq!(got[0].line.spans[0].content, "hello");
177 }
178
179 #[test]
180 fn revision_bump_invalidates_cell() {
181 let mut cache = TranscriptCache::new();
182 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
183 // Hit at rev=1
184 assert!(cache.get(CellId::History(0), 80, 1).is_some());
185 // Miss at rev=2 — caller is expected to re-wrap and insert again.
186 assert!(cache.get(CellId::History(0), 80, 2).is_none());
187 }
188
189 #[test]
190 fn width_change_invalidates_cell() {
191 let mut cache = TranscriptCache::new();
192 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
193 assert!(cache.get(CellId::History(0), 80, 1).is_some());
194 assert!(cache.get(CellId::History(0), 100, 1).is_none());
195 }
196
197 #[test]
198 fn active_cells_are_distinct_from_history() {
199 let mut cache = TranscriptCache::new();
200 cache.insert(CellId::History(0), 80, 1, vec![line("history")]);
201 cache.insert(CellId::Active(0), 80, 1, vec![line("active")]);
202 assert_eq!(
203 cache.get(CellId::History(0), 80, 1).unwrap()[0].line.spans[0].content,
204 "history"
205 );
206 assert_eq!(
207 cache.get(CellId::Active(0), 80, 1).unwrap()[0].line.spans[0].content,
208 "active"
209 );
210 }
211
212 #[test]
213 fn reinsert_same_key_does_not_evict() {
214 // Capacity 2 — re-inserting an existing key must not cause the other
215 // entry to be evicted; otherwise re-rendering the same cell on every
216 // frame would churn unrelated entries out of the cache.
217 let mut cache = TranscriptCache::with_capacity(2);
218 cache.insert(CellId::History(0), 80, 1, vec![line("a")]);
219 cache.insert(CellId::History(1), 80, 1, vec![line("b")]);
220 cache.insert(CellId::History(0), 80, 1, vec![line("a-prime")]);
221 assert!(cache.get(CellId::History(1), 80, 1).is_some());
222 }
223
224 #[test]
225 fn capacity_evicts_oldest_on_overflow() {
226 let mut cache = TranscriptCache::with_capacity(2);
227 cache.insert(CellId::History(0), 80, 1, vec![line("a")]);
228 cache.insert(CellId::History(1), 80, 1, vec![line("b")]);
229 cache.insert(CellId::History(2), 80, 1, vec![line("c")]);
230 // Oldest (History(0)) should be gone; the two newer keys remain.
231 assert!(cache.get(CellId::History(0), 80, 1).is_none());
232 assert!(cache.get(CellId::History(1), 80, 1).is_some());
233 assert!(cache.get(CellId::History(2), 80, 1).is_some());
234 assert_eq!(cache.len(), 2);
235 }
236
237 #[test]
238 fn clear_drops_everything() {
239 let mut cache = TranscriptCache::new();
240 cache.insert(CellId::History(0), 80, 1, vec![line("v1")]);
241 cache.clear();
242 assert!(cache.get(CellId::History(0), 80, 1).is_none());
243 assert_eq!(cache.len(), 0);
244 }
245 }
246
246 lines RUST