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