1use std::borrow::Cow;
4use std::collections::HashMap;
5use std::hash::{DefaultHasher, Hash, Hasher};
6
7use super::{App, DisplayMessage, DisplayRole};
8
9fn 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
17fn 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 if msg.thinking_duration_secs.is_none()
27 && let Some(started) = msg.thinking_started_at
28 {
29 (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 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 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 let new_hashes: Vec<u64> = self
98 .state
99 .messages
100 .iter()
101 .map(display_message_hash)
102 .collect();
103
104 let mut first_dirty = {
106 let old_len = self.state.per_message_hashes.len();
107 if old_len > num_messages {
108 0 } 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 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 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 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 first_dirty = first_dirty.min(self.state.per_message_culled.len());
202 }
203
204 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 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 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 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 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 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 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 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 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); let label = " Plan ";
493 let top_after = border_w.saturating_sub(3 + label.len() + 1); 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 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 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 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 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 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 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 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 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;