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