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