zeph_tui/render_cache.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ratatui::text::Line;
5
6use crate::widgets::chat::MdLink;
7use crate::widgets::tool_view::ToolDensity;
8
9/// Cache key for a single rendered chat message.
10///
11/// Two keys compare equal only when the content, terminal width, theme generation,
12/// and all display flags are identical. Any mismatch causes a cache miss and
13/// re-render. The `theme_generation` field increments on every theme swap,
14/// forcing a cache miss after the user switches themes so that cached `Line`s
15/// (which bake in theme `Style` values) are not reused with stale colours.
16///
17/// # Examples
18///
19/// ```rust
20/// use zeph_tui::render_cache::RenderCacheKey;
21/// use zeph_config::ToolDensity;
22///
23/// let k1 = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
24/// let k2 = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
25/// assert_eq!(k1, k2);
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct RenderCacheKey {
29 /// FNV/xxHash of the message content string.
30 pub content_hash: u64,
31 /// Terminal column width at the time of rendering.
32 pub terminal_width: u16,
33 /// Whether the tool-output section is expanded.
34 pub tool_expanded: bool,
35 /// Current tool-output density level.
36 pub tool_density: ToolDensity,
37 /// Whether source-label badges are shown on assistant messages.
38 pub show_labels: bool,
39 /// Monotonic counter bumped on each theme swap; forces cache miss after a switch.
40 pub theme_generation: u64,
41}
42
43/// A single cached render result for a chat message.
44///
45/// Stores the pre-rendered [`ratatui::text::Line`] vector and extracted
46/// markdown link metadata. Both are reused verbatim on cache hits.
47pub struct RenderCacheEntry {
48 /// The key this entry was computed for.
49 pub key: RenderCacheKey,
50 /// Pre-rendered lines ready for the chat widget.
51 pub lines: Vec<Line<'static>>,
52 /// Markdown hyperlink spans extracted during rendering.
53 pub md_links: Vec<MdLink>,
54}
55
56/// Per-message render cache keyed by message index.
57///
58/// The cache stores one optional entry per chat message, addressed by the
59/// message's position in [`crate::App`]'s message buffer. On each frame the
60/// chat widget calls [`get`](Self::get) with the current [`RenderCacheKey`];
61/// on a hit it reuses the cached lines, skipping expensive markdown parsing
62/// and word-wrapping.
63///
64/// When messages are evicted from the front of the buffer, call
65/// [`shift`](Self::shift) to keep indices aligned.
66///
67/// # Examples
68///
69/// ```rust
70/// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
71/// use zeph_config::ToolDensity;
72///
73/// let mut cache = RenderCache::default();
74/// let key = RenderCacheKey { content_hash: 42, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
75/// cache.put(0, key, vec![], vec![]);
76/// assert!(cache.get(0, &key).is_some());
77/// ```
78#[derive(Default)]
79pub struct RenderCache {
80 entries: Vec<Option<RenderCacheEntry>>,
81}
82
83impl RenderCache {
84 /// Look up cached lines for message at `idx` with the given `key`.
85 ///
86 /// Returns `Some((lines, md_links))` on a cache hit, `None` on a miss or
87 /// key mismatch.
88 ///
89 /// # Examples
90 ///
91 /// ```rust
92 /// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
93 /// use zeph_config::ToolDensity;
94 ///
95 /// let mut cache = RenderCache::default();
96 /// let key = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
97 /// assert!(cache.get(0, &key).is_none()); // cold cache
98 /// ```
99 pub fn get(&self, idx: usize, key: &RenderCacheKey) -> Option<(&[Line<'static>], &[MdLink])> {
100 self.entries
101 .get(idx)
102 .and_then(Option::as_ref)
103 .filter(|e| &e.key == key)
104 .map(|e| (e.lines.as_slice(), e.md_links.as_slice()))
105 }
106
107 /// Store a rendered entry for message at `idx`.
108 ///
109 /// Grows the internal storage as needed. An existing entry at `idx` is
110 /// unconditionally replaced.
111 ///
112 /// # Examples
113 ///
114 /// ```rust
115 /// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
116 /// use zeph_config::ToolDensity;
117 ///
118 /// let mut cache = RenderCache::default();
119 /// let key = RenderCacheKey { content_hash: 7, terminal_width: 100, tool_expanded: true, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
120 /// cache.put(0, key, vec![], vec![]);
121 /// assert!(cache.get(0, &key).is_some());
122 /// ```
123 pub fn put(
124 &mut self,
125 idx: usize,
126 key: RenderCacheKey,
127 lines: Vec<Line<'static>>,
128 md_links: Vec<MdLink>,
129 ) {
130 if idx >= self.entries.len() {
131 self.entries.resize_with(idx + 1, || None);
132 }
133 self.entries[idx] = Some(RenderCacheEntry {
134 key,
135 lines,
136 md_links,
137 });
138 }
139
140 /// Invalidate the entry at `idx`, forcing a re-render on the next frame.
141 ///
142 /// A no-op if `idx` is out of range.
143 ///
144 /// # Examples
145 ///
146 /// ```rust
147 /// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
148 /// use zeph_config::ToolDensity;
149 ///
150 /// let mut cache = RenderCache::default();
151 /// let key = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
152 /// cache.put(0, key, vec![], vec![]);
153 /// cache.invalidate(0);
154 /// assert!(cache.get(0, &key).is_none());
155 /// ```
156 pub fn invalidate(&mut self, idx: usize) {
157 if let Some(entry) = self.entries.get_mut(idx) {
158 *entry = None;
159 }
160 }
161
162 /// Remove all cached entries.
163 ///
164 /// # Examples
165 ///
166 /// ```rust
167 /// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
168 /// use zeph_config::ToolDensity;
169 ///
170 /// let mut cache = RenderCache::default();
171 /// let key = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
172 /// cache.put(0, key, vec![], vec![]);
173 /// cache.clear();
174 /// assert!(cache.get(0, &key).is_none());
175 /// ```
176 pub fn clear(&mut self) {
177 self.entries = Vec::new();
178 }
179
180 /// Shift all entries left by `count` positions.
181 ///
182 /// Called when `count` messages are evicted from the front of the message
183 /// buffer, so that cache index `N` continues to map to message index `N`.
184 /// If `count` >= the current number of entries, the cache is emptied.
185 ///
186 /// # Examples
187 ///
188 /// ```rust
189 /// use zeph_tui::render_cache::{RenderCache, RenderCacheKey};
190 /// use zeph_config::ToolDensity;
191 ///
192 /// let mut cache = RenderCache::default();
193 /// for i in 0..3u64 {
194 /// let key = RenderCacheKey { content_hash: i, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
195 /// cache.put(i as usize, key, vec![], vec![]);
196 /// }
197 /// cache.shift(1);
198 /// // Old index 1 is now at index 0.
199 /// let key1 = RenderCacheKey { content_hash: 1, terminal_width: 80, tool_expanded: false, tool_density: ToolDensity::Inline, show_labels: false, theme_generation: 0 };
200 /// assert!(cache.get(0, &key1).is_some());
201 /// ```
202 pub fn shift(&mut self, count: usize) {
203 if count >= self.entries.len() {
204 self.entries = Vec::new();
205 } else {
206 self.entries.drain(0..count);
207 }
208 }
209}
210
211/// Compute a fast, non-cryptographic hash of a string for cache keying.
212///
213/// The underlying algorithm is [`zeph_common::hash::fast_hash`] (xxHash or
214/// similar). The result is stable within a process but should not be persisted.
215///
216/// # Examples
217///
218/// ```rust
219/// use zeph_tui::render_cache::content_hash;
220///
221/// let h = content_hash("hello");
222/// assert_eq!(h, content_hash("hello")); // deterministic
223/// assert_ne!(h, content_hash("world")); // distinct inputs → distinct hashes
224/// ```
225#[must_use]
226pub fn content_hash(s: &str) -> u64 {
227 zeph_common::hash::fast_hash(s)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn make_key(hash: u64) -> RenderCacheKey {
235 RenderCacheKey {
236 content_hash: hash,
237 terminal_width: 80,
238 tool_expanded: false,
239 tool_density: ToolDensity::Inline,
240 show_labels: false,
241 theme_generation: 0,
242 }
243 }
244
245 fn populated_cache(count: usize) -> RenderCache {
246 let mut cache = RenderCache::default();
247 for i in 0..count {
248 cache.put(i, make_key(i as u64), vec![], vec![]);
249 }
250 cache
251 }
252
253 #[test]
254 fn shift_zero_is_noop() {
255 let mut cache = populated_cache(3);
256 cache.shift(0);
257 assert!(cache.get(0, &make_key(0)).is_some());
258 assert!(cache.get(1, &make_key(1)).is_some());
259 assert!(cache.get(2, &make_key(2)).is_some());
260 }
261
262 #[test]
263 fn shift_count_equals_len_empties_cache() {
264 let mut cache = populated_cache(3);
265 cache.shift(3);
266 assert!(cache.get(0, &make_key(0)).is_none());
267 assert!(cache.get(1, &make_key(1)).is_none());
268 }
269
270 #[test]
271 fn shift_count_greater_than_len_empties_cache() {
272 let mut cache = populated_cache(3);
273 cache.shift(10);
274 assert!(cache.get(0, &make_key(0)).is_none());
275 }
276
277 #[test]
278 fn shift_partial_preserves_remaining_entries() {
279 let mut cache = populated_cache(5);
280 // entries at indices 0,1,2,3,4 have keys with hash 0,1,2,3,4
281 cache.shift(2);
282 // after shift: old index 2 → new index 0, old index 3 → new index 1, etc.
283 assert!(cache.get(0, &make_key(2)).is_some());
284 assert!(cache.get(1, &make_key(3)).is_some());
285 assert!(cache.get(2, &make_key(4)).is_some());
286 assert!(cache.get(3, &make_key(0)).is_none()); // out of bounds or wrong key
287 }
288}