| 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 |