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