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