Skip to main content

opendev_tui/app/
cache.rs

1//! Conversation message caching and incremental rebuild logic.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::hash::{DefaultHasher, Hash, Hasher};
6
7use super::{App, DisplayMessage, DisplayRole};
8
9/// Compute a hash key for markdown cache lookup from role and content.
10fn markdown_cache_key(role: &DisplayRole, content: &str) -> u64 {
11    let mut hasher = DefaultHasher::new();
12    std::mem::discriminant(role).hash(&mut hasher);
13    content.hash(&mut hasher);
14    hasher.finish()
15}
16
17/// Compute a content hash for a `DisplayMessage` used by per-message dirty tracking.
18fn display_message_hash(msg: &DisplayMessage) -> u64 {
19    let mut hasher = DefaultHasher::new();
20    std::mem::discriminant(&msg.role).hash(&mut hasher);
21    msg.content.hash(&mut hasher);
22    msg.collapsed.hash(&mut hasher);
23    msg.thinking_duration_secs.hash(&mut hasher);
24    // For unfinalized reasoning, hash elapsed millis (tick-aligned) to drive
25    // shimmer animation and elapsed timer updates without excessive re-renders
26    if msg.thinking_duration_secs.is_none()
27        && let Some(started) = msg.thinking_started_at
28    {
29        // ~30fps: changes every 33ms, matching the tick rate
30        (started.elapsed().as_millis() / 33).hash(&mut hasher);
31    }
32    if let Some(ref tc) = msg.tool_call {
33        tc.name.hash(&mut hasher);
34        format!("{:?}", tc.arguments).hash(&mut hasher);
35        tc.summary.hash(&mut hasher);
36        tc.success.hash(&mut hasher);
37        tc.collapsed.hash(&mut hasher);
38        tc.result_lines.hash(&mut hasher);
39        tc.nested_calls.len().hash(&mut hasher);
40        for nested in &tc.nested_calls {
41            nested.name.hash(&mut hasher);
42            nested.success.hash(&mut hasher);
43            format!("{:?}", nested.arguments).hash(&mut hasher);
44        }
45    }
46    hasher.finish()
47}
48
49impl App {
50    fn conversation_viewport_height(&self) -> usize {
51        let todo_height = crate::widgets::todo_panel_height(
52            self.state.todo_items.len(),
53            self.state.todo_expanded,
54        );
55        let input_lines = self.state.input_buffer.matches('\n').count() + 1;
56        let input_height = (input_lines as u16 + 1).min(8);
57        let conv_height = self
58            .state
59            .terminal_height
60            .saturating_sub(todo_height)
61            .saturating_sub(input_height)
62            .saturating_sub(2)
63            .max(5);
64        conv_height.saturating_sub(1) as usize
65    }
66
67    pub fn clear_markdown_cache(&mut self) {
68        self.state.markdown_cache.clear();
69    }
70
71    /// Rebuild the cached static conversation lines from messages.
72    ///
73    /// Uses per-message dirty tracking: each message's content is hashed and
74    /// compared with the stored hash. Only messages whose hash changed or that
75    /// are new get re-rendered. If a message in the middle changed, we rebuild
76    /// from that point forward.
77    ///
78    /// Viewport culling is still applied: messages far above the visible viewport
79    /// emit placeholder blank lines to preserve scroll math.
80    pub(super) fn rebuild_cached_lines(&mut self) {
81        use crate::formatters::display::strip_system_reminders;
82
83        let num_messages = self.state.messages.len();
84        let content_width = self.state.terminal_width.saturating_sub(1);
85
86        // Width-change detection: if terminal was resized, clear all caches
87        if self.state.cached_width != content_width {
88            self.state.cached_width = content_width;
89            self.state.cached_lines.clear();
90            self.state.per_message_hashes.clear();
91            self.state.per_message_line_counts.clear();
92            self.state.per_message_culled.clear();
93            self.state.markdown_cache.clear();
94        }
95
96        // Compute per-message hashes for the current messages
97        let new_hashes: Vec<u64> = self
98            .state
99            .messages
100            .iter()
101            .map(display_message_hash)
102            .collect();
103
104        // Find the first message index where the hash differs
105        let mut first_dirty = {
106            let old_len = self.state.per_message_hashes.len();
107            if old_len > num_messages {
108                0 // Messages were removed -- full rebuild
109            } else {
110                let mut dirty_idx = old_len;
111                for (i, new_hash) in new_hashes
112                    .iter()
113                    .enumerate()
114                    .take(old_len.min(num_messages))
115                {
116                    if self.state.per_message_hashes[i] != *new_hash {
117                        dirty_idx = i;
118                        break;
119                    }
120                }
121                dirty_idx
122            }
123        };
124
125        // --- Viewport culling ---
126        let viewport_h = self.conversation_viewport_height();
127        let mut buffer_lines = 100usize;
128        if self.state.task_progress.is_some()
129            || !self.state.active_tools.is_empty()
130            || !self.state.active_subagents.is_empty()
131            || self.state.agent_active
132        {
133            buffer_lines = buffer_lines.max(viewport_h.saturating_mul(4));
134        }
135        let visible_from_bottom = self.state.scroll_offset as usize + viewport_h + buffer_lines;
136
137        let msg_line_estimates: Vec<usize> = self
138            .state
139            .messages
140            .iter()
141            .map(|msg| {
142                let content = strip_system_reminders(&msg.content);
143                let text_lines = if content.is_empty() {
144                    0
145                } else {
146                    content.lines().count()
147                };
148                let tool_lines = if let Some(ref tc) = msg.tool_call {
149                    use crate::formatters::tool_registry::{ResultFormat, lookup_tool};
150                    let is_bash = lookup_tool(&tc.name).result_format == ResultFormat::Bash;
151                    1 + if !tc.collapsed {
152                        tc.result_lines.len()
153                    } else if is_bash {
154                        // Bash preview: ≤4 lines shown inline, >4 shows 5 (2+ellipsis+2), 0 shows 1
155                        let n = tc.result_lines.len();
156                        if n == 0 {
157                            1
158                        } else {
159                            n.min(4).max(if n > 4 { 5 } else { n })
160                        }
161                    } else if !tc.result_lines.is_empty() {
162                        1
163                    } else {
164                        0
165                    } + tc.nested_calls.len()
166                } else {
167                    0
168                };
169                text_lines + tool_lines + 1
170            })
171            .collect();
172
173        let total_estimated: usize = msg_line_estimates.iter().sum();
174        let cull_start = total_estimated.saturating_sub(visible_from_bottom);
175        let mut cumulative = 0usize;
176        let msg_visible: Vec<bool> = msg_line_estimates
177            .iter()
178            .map(|&est| {
179                let msg_end = cumulative + est;
180                cumulative = msg_end;
181                msg_end > cull_start
182            })
183            .collect();
184
185        // Detect culling state changes (messages transitioning visible <-> culled).
186        // When the user scrolls, previously-culled messages may enter the viewport
187        // and need to be re-rendered from their blank placeholders.
188        if self.state.per_message_culled.len() == num_messages {
189            for (i, (new_vis, old_vis)) in msg_visible
190                .iter()
191                .zip(self.state.per_message_culled.iter())
192                .enumerate()
193            {
194                if new_vis != old_vis {
195                    first_dirty = first_dirty.min(i);
196                    break;
197                }
198            }
199        } else if !self.state.per_message_culled.is_empty() {
200            // Length mismatch (messages added/removed) — already handled by hash check
201            first_dirty = first_dirty.min(self.state.per_message_culled.len());
202        }
203
204        // Nothing changed (content hashes match AND culling state unchanged)
205        if first_dirty >= num_messages && self.state.per_message_hashes.len() == num_messages {
206            self.state.per_message_culled = msg_visible;
207            return;
208        }
209
210        // If the first dirty message attaches to its predecessor, re-render that
211        // predecessor too so its trailing blank line can be suppressed.
212        let first_dirty = if first_dirty > 0
213            && self
214                .state
215                .messages
216                .get(first_dirty)
217                .and_then(|m| m.role.style())
218                .is_some_and(|s| s.attach_to_previous)
219        {
220            first_dirty - 1
221        } else {
222            first_dirty
223        };
224
225        // Truncate to the point before first_dirty
226        let lines_to_keep: usize = self
227            .state
228            .per_message_line_counts
229            .iter()
230            .take(first_dirty)
231            .sum();
232        self.state.cached_lines.truncate(lines_to_keep);
233        self.state.per_message_hashes.truncate(first_dirty);
234        self.state.per_message_line_counts.truncate(first_dirty);
235
236        // Re-render only messages from first_dirty onward
237        for msg_idx in first_dirty..num_messages {
238            let msg = &self.state.messages[msg_idx];
239            let lines_before = self.state.cached_lines.len();
240
241            if !msg_visible[msg_idx] {
242                let est = msg_line_estimates[msg_idx];
243                for _ in 0..est {
244                    self.state.cached_lines.push(ratatui::text::Line::from(""));
245                }
246            } else {
247                let next_role = self.state.messages.get(msg_idx + 1).map(|m| &m.role);
248                Self::render_single_message(
249                    msg,
250                    next_role,
251                    &mut self.state.cached_lines,
252                    &mut self.state.markdown_cache,
253                    &self.state.path_shortener,
254                    content_width,
255                    self.state.spinner.tick_count(),
256                );
257            }
258
259            let lines_produced = self.state.cached_lines.len() - lines_before;
260            self.state.per_message_hashes.push(new_hashes[msg_idx]);
261            self.state.per_message_line_counts.push(lines_produced);
262        }
263
264        self.state.per_message_culled = msg_visible;
265    }
266
267    /// Render a single `DisplayMessage` into styled lines, appending to `lines`.
268    /// `next_role` is the role of the following message (if any), used to suppress
269    /// the trailing blank line before messages that attach to the previous one.
270    /// `content_width` is the available display width for word-wrapping (0 = no wrapping).
271    pub(super) fn render_single_message(
272        msg: &DisplayMessage,
273        next_role: Option<&DisplayRole>,
274        lines: &mut Vec<ratatui::text::Line<'static>>,
275        markdown_cache: &mut HashMap<u64, Vec<ratatui::text::Line<'static>>>,
276        shortener: &crate::formatters::PathShortener,
277        content_width: u16,
278        tick_count: u64,
279    ) {
280        use crate::formatters::display::strip_system_reminders;
281        use crate::formatters::markdown::MarkdownRenderer;
282        use crate::formatters::style_tokens::{self, Indent};
283        use crate::formatters::tool_registry::{ResultFormat, format_tool_call_parts_short};
284        use crate::formatters::wrap::wrap_spans_to_lines;
285        use crate::widgets::conversation::build_bash_preview;
286        use crate::widgets::spinner::{COMPLETED_CHAR, CONTINUATION_CHAR};
287        use ratatui::style::{Modifier, Style};
288        use ratatui::text::{Line, Span};
289
290        let content = strip_system_reminders(&msg.content);
291        if content.is_empty() && msg.tool_call.is_none() {
292            return;
293        }
294
295        let max_w = content_width as usize;
296
297        match msg.role {
298            DisplayRole::Assistant => {
299                let cache_key = markdown_cache_key(&msg.role, &content);
300                let md_lines = if let Some(cached) = markdown_cache.get(&cache_key) {
301                    cached.clone()
302                } else {
303                    let rendered = MarkdownRenderer::render(&content);
304                    markdown_cache.insert(cache_key, rendered.clone());
305                    rendered
306                };
307
308                let first_prefix = vec![Span::styled(
309                    format!("{} ", COMPLETED_CHAR),
310                    Style::default().fg(style_tokens::GREEN_BRIGHT),
311                )];
312                let cont_prefix = vec![Span::raw(Indent::CONT)];
313
314                if max_w > 0 {
315                    let wrapped = wrap_spans_to_lines(md_lines, first_prefix, cont_prefix, max_w);
316                    lines.extend(wrapped);
317                } else {
318                    // Fallback: no wrapping (width unknown)
319                    let mut leading_consumed = false;
320                    for md_line in md_lines {
321                        let line_text: String = md_line
322                            .spans
323                            .iter()
324                            .map(|s| s.content.to_string())
325                            .collect();
326                        let has_content = !line_text.trim().is_empty();
327
328                        if !leading_consumed && has_content {
329                            let mut spans = first_prefix.clone();
330                            spans.extend(
331                                md_line
332                                    .spans
333                                    .into_iter()
334                                    .map(|s| Span::styled(s.content.to_string(), s.style)),
335                            );
336                            lines.push(Line::from(spans));
337                            leading_consumed = true;
338                        } else {
339                            let mut spans = cont_prefix.clone();
340                            spans.extend(
341                                md_line
342                                    .spans
343                                    .into_iter()
344                                    .map(|s| Span::styled(s.content.to_string(), s.style)),
345                            );
346                            lines.push(Line::from(spans));
347                        }
348                    }
349                }
350            }
351            DisplayRole::System => {
352                let subtle_style = Style::default().fg(style_tokens::SUBTLE);
353                for (i, line_text) in content.lines().enumerate() {
354                    if i == 0 {
355                        lines.push(Line::from(vec![
356                            Span::styled(
357                                format!("{} ", COMPLETED_CHAR),
358                                Style::default().fg(style_tokens::WARNING),
359                            ),
360                            Span::styled(line_text.to_string(), subtle_style),
361                        ]));
362                    } else {
363                        lines.push(Line::from(vec![
364                            Span::raw(Indent::CONT),
365                            Span::styled(line_text.to_string(), subtle_style),
366                        ]));
367                    }
368                }
369            }
370            DisplayRole::User
371            | DisplayRole::Interrupt
372            | DisplayRole::SlashCommand
373            | DisplayRole::CommandResult => {
374                let rs = msg.role.style().unwrap();
375                for (i, line_text) in content.lines().enumerate() {
376                    if i == 0 {
377                        lines.push(Line::from(vec![
378                            Span::styled(rs.icon.clone(), rs.icon_style),
379                            Span::styled(line_text.to_string(), Style::default().fg(rs.text_color)),
380                        ]));
381                    } else {
382                        lines.push(Line::from(vec![
383                            Span::raw(rs.continuation),
384                            Span::styled(line_text.to_string(), Style::default().fg(rs.text_color)),
385                        ]));
386                    }
387                }
388            }
389            DisplayRole::Reasoning => {
390                let thinking_style = Style::default().fg(style_tokens::THINKING_BG);
391
392                if msg.collapsed {
393                    if let Some(secs) = msg.thinking_duration_secs {
394                        // Finalized collapsed: "⟡ Thought for Xs (Ctrl+I to expand)"
395                        let duration_text = if secs == 0 {
396                            "<1".to_string()
397                        } else {
398                            secs.to_string()
399                        };
400                        lines.push(Line::from(vec![
401                            Span::styled(
402                                format!(
403                                    "{} Thought for {}s",
404                                    style_tokens::THINKING_ICON,
405                                    duration_text
406                                ),
407                                thinking_style,
408                            ),
409                            Span::styled(
410                                " (Ctrl+I to expand)",
411                                Style::default().fg(style_tokens::SUBTLE),
412                            ),
413                        ]));
414                    } else if let Some(started) = msg.thinking_started_at {
415                        // Streaming: shimmer wave "⟡ Thinking... Xs (Ctrl+I to expand)"
416                        let elapsed = started.elapsed().as_secs();
417                        let text =
418                            format!("{} Thinking... {}s", style_tokens::THINKING_ICON, elapsed);
419                        let highlight = ratatui::style::Color::Rgb(200, 200, 220);
420                        let mut spans = style_tokens::shimmer_line(
421                            &text,
422                            tick_count,
423                            style_tokens::THINKING_BG,
424                            highlight,
425                        );
426                        spans.push(Span::styled(
427                            " (Ctrl+I to expand)",
428                            Style::default().fg(style_tokens::SUBTLE),
429                        ));
430                        lines.push(Line::from(spans));
431                    }
432                } else {
433                    // Expanded: full markdown rendering (unchanged)
434                    let cache_key = markdown_cache_key(&msg.role, &content);
435                    let md_lines = if let Some(cached) = markdown_cache.get(&cache_key) {
436                        cached.clone()
437                    } else {
438                        let rendered =
439                            MarkdownRenderer::render_muted(&content, style_tokens::THINKING_BG);
440                        markdown_cache.insert(cache_key, rendered.clone());
441                        rendered
442                    };
443
444                    let first_prefix = vec![Span::styled(
445                        format!("{} ", style_tokens::THINKING_ICON),
446                        thinking_style,
447                    )];
448                    let cont_prefix = vec![Span::styled(Indent::THINKING_CONT, thinking_style)];
449
450                    if max_w > 0 {
451                        let wrapped =
452                            wrap_spans_to_lines(md_lines, first_prefix, cont_prefix, max_w);
453                        lines.extend(wrapped);
454                    } else {
455                        let mut leading_consumed = false;
456                        for md_line in md_lines {
457                            let line_text: String = md_line
458                                .spans
459                                .iter()
460                                .map(|s| s.content.to_string())
461                                .collect();
462                            let has_content = !line_text.trim().is_empty();
463
464                            if !leading_consumed && has_content {
465                                let mut spans = first_prefix.clone();
466                                spans.extend(
467                                    md_line
468                                        .spans
469                                        .into_iter()
470                                        .map(|s| Span::styled(s.content.to_string(), s.style)),
471                                );
472                                lines.push(Line::from(spans));
473                                leading_consumed = true;
474                            } else {
475                                let mut spans = cont_prefix.clone();
476                                spans.extend(
477                                    md_line
478                                        .spans
479                                        .into_iter()
480                                        .map(|s| Span::styled(s.content.to_string(), s.style)),
481                                );
482                                lines.push(Line::from(spans));
483                            }
484                        }
485                    }
486                }
487            }
488            DisplayRole::Plan => {
489                let border_style = Style::default().fg(style_tokens::CYAN);
490                let border_w: usize = if max_w > 0 { max_w } else { 32 };
491                let inner_w = border_w.saturating_sub(1); // leave room for right │
492                let label = " Plan ";
493                let top_after = border_w.saturating_sub(3 + label.len() + 1); // 3 = ╭── prefix, +1 for ╮ suffix
494
495                // Top border: ╭── Plan ──────────────────╮
496                lines.push(Line::from(vec![
497                    Span::styled(
498                        format!("{}{}", style_tokens::BOX_TL, style_tokens::BOX_H.repeat(2)),
499                        border_style,
500                    ),
501                    Span::styled(
502                        label.to_string(),
503                        border_style.add_modifier(ratatui::style::Modifier::BOLD),
504                    ),
505                    Span::styled(
506                        format!(
507                            "{}{}",
508                            style_tokens::BOX_H.repeat(top_after),
509                            style_tokens::BOX_TR
510                        ),
511                        border_style,
512                    ),
513                ]));
514
515                // Top padding
516                lines.push(Line::from(vec![
517                    Span::styled(style_tokens::BOX_V.to_string(), border_style),
518                    Span::raw(" ".repeat(inner_w.saturating_sub(1))),
519                    Span::styled(style_tokens::BOX_V.to_string(), border_style),
520                ]));
521
522                // Markdown content with left border prefix
523                let cache_key = markdown_cache_key(&msg.role, &content);
524                let md_lines = if let Some(cached) = markdown_cache.get(&cache_key) {
525                    cached.clone()
526                } else {
527                    let rendered = MarkdownRenderer::render(&content);
528                    markdown_cache.insert(cache_key, rendered.clone());
529                    rendered
530                };
531
532                let prefix_str = format!("{}  ", style_tokens::BOX_V);
533                let prefix_span = vec![Span::styled(prefix_str.clone(), border_style)];
534                let cont_span = vec![Span::styled(prefix_str, border_style)];
535
536                let wrap_width = if max_w > 0 { inner_w } else { 0 };
537                let content_lines = if wrap_width > 0 {
538                    wrap_spans_to_lines(md_lines, prefix_span, cont_span, wrap_width)
539                } else {
540                    let mut out = Vec::new();
541                    for md_line in md_lines {
542                        let mut spans = prefix_span.clone();
543                        spans.extend(
544                            md_line
545                                .spans
546                                .into_iter()
547                                .map(|s| Span::styled(s.content.to_string(), s.style)),
548                        );
549                        out.push(Line::from(spans));
550                    }
551                    out
552                };
553
554                // Add right border to each content line
555                for mut line in content_lines {
556                    if border_w > 0 {
557                        let line_w = line.width();
558                        let pad = inner_w.saturating_sub(line_w);
559                        if pad > 0 {
560                            line.spans.push(Span::raw(" ".repeat(pad)));
561                        }
562                        line.spans
563                            .push(Span::styled(style_tokens::BOX_V.to_string(), border_style));
564                    }
565                    lines.push(line);
566                }
567
568                // Bottom padding
569                lines.push(Line::from(vec![
570                    Span::styled(style_tokens::BOX_V.to_string(), border_style),
571                    Span::raw(" ".repeat(inner_w.saturating_sub(1))),
572                    Span::styled(style_tokens::BOX_V.to_string(), border_style),
573                ]));
574
575                // Bottom border: ╰──────────────────────────╯
576                lines.push(Line::from(vec![Span::styled(
577                    format!(
578                        "{}{}{}",
579                        style_tokens::BOX_BL,
580                        style_tokens::BOX_H.repeat(border_w.saturating_sub(2)),
581                        style_tokens::BOX_BR
582                    ),
583                    border_style,
584                )]));
585            }
586        }
587
588        // Tool call summary
589        if let Some(ref tc) = msg.tool_call {
590            let (icon, icon_color) = if tc.success {
591                (COMPLETED_CHAR, style_tokens::GREEN_BRIGHT)
592            } else {
593                (COMPLETED_CHAR, style_tokens::ERROR)
594            };
595            let (verb, arg) = format_tool_call_parts_short(&tc.name, &tc.arguments, shortener);
596            lines.push(Line::from(vec![
597                Span::styled(format!("{icon} "), Style::default().fg(icon_color)),
598                Span::styled(
599                    verb,
600                    Style::default()
601                        .fg(style_tokens::PRIMARY)
602                        .add_modifier(Modifier::BOLD),
603                ),
604                Span::styled(format!(" {arg}"), Style::default().fg(style_tokens::SUBTLE)),
605            ]));
606
607            // Diff tools are never collapsed
608            use crate::widgets::conversation::{
609                is_diff_tool, parse_unified_diff, render_diff_entries,
610            };
611            let is_bash = crate::formatters::tool_registry::lookup_tool(&tc.name).result_format
612                == ResultFormat::Bash;
613            let effective_collapsed = tc.collapsed && !is_diff_tool(&tc.name);
614            if !effective_collapsed && !tc.result_lines.is_empty() {
615                let use_diff = is_diff_tool(&tc.name);
616                if use_diff {
617                    let (summary, entries) = parse_unified_diff(&tc.result_lines);
618                    if !summary.is_empty() {
619                        lines.push(Line::from(vec![
620                            Span::styled(
621                                format!("  {}  ", CONTINUATION_CHAR),
622                                Style::default().fg(style_tokens::GREY),
623                            ),
624                            Span::styled(summary, Style::default().fg(style_tokens::SUBTLE)),
625                        ]));
626                    }
627                    render_diff_entries(&entries, lines);
628                } else {
629                    for (i, result_line) in tc.result_lines.iter().enumerate() {
630                        let prefix_char: Cow<'static, str> = if i == 0 {
631                            format!("  {}  ", CONTINUATION_CHAR).into()
632                        } else {
633                            Cow::Borrowed(Indent::RESULT_CONT)
634                        };
635                        let shortened = shortener.shorten_text(result_line);
636                        lines.push(Line::from(vec![
637                            Span::styled(prefix_char, Style::default().fg(style_tokens::SUBTLE)),
638                            Span::styled(shortened, Style::default().fg(style_tokens::SUBTLE)),
639                        ]));
640                    }
641                }
642            } else if effective_collapsed {
643                if is_bash {
644                    lines.extend(build_bash_preview(&tc.result_lines));
645                } else if !tc.result_lines.is_empty() {
646                    let count = tc.result_lines.len();
647                    let verb = crate::formatters::tool_registry::lookup_tool(&tc.name).verb;
648                    let label = format!("  {}  {verb} {count} lines", CONTINUATION_CHAR);
649                    lines.push(Line::from(Span::styled(
650                        label,
651                        Style::default().fg(style_tokens::SUBTLE),
652                    )));
653                }
654            } else if tc.result_lines.is_empty() && is_bash {
655                lines.extend(build_bash_preview(&tc.result_lines));
656            }
657
658            for nested in &tc.nested_calls {
659                let (n_icon, n_icon_color) = if nested.success {
660                    (COMPLETED_CHAR, style_tokens::GREEN_BRIGHT)
661                } else {
662                    (COMPLETED_CHAR, style_tokens::ERROR)
663                };
664                let (n_verb, n_arg) =
665                    format_tool_call_parts_short(&nested.name, &nested.arguments, shortener);
666                lines.push(Line::from(vec![
667                    Span::styled(
668                        format!("{}\u{2514}\u{2500} ", Indent::CONT),
669                        Style::default().fg(style_tokens::SUBTLE),
670                    ),
671                    Span::styled(format!("{n_icon} "), Style::default().fg(n_icon_color)),
672                    Span::styled(
673                        n_verb,
674                        Style::default()
675                            .fg(style_tokens::PRIMARY)
676                            .add_modifier(Modifier::BOLD),
677                    ),
678                    Span::styled(
679                        format!(" {n_arg}"),
680                        Style::default().fg(style_tokens::SUBTLE),
681                    ),
682                ]));
683            }
684        }
685
686        // Blank line between messages — skip before messages that attach to previous
687        let next_attaches = next_role
688            .and_then(|r| r.style())
689            .is_some_and(|s| s.attach_to_previous);
690        if !next_attaches {
691            lines.push(Line::from(""));
692        }
693    }
694}
695
696#[cfg(test)]
697#[path = "cache_tests.rs"]
698mod tests;