Skip to main content

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}