1use imp_core::config::{AnimationLevel, ChatToolDisplay};
2use ratatui::buffer::Buffer;
3use ratatui::layout::Rect;
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Widget;
7
8use crate::animation::{activity_label, ActivitySurface, AnimationState};
9use crate::highlight::Highlighter;
10use crate::markdown;
11use crate::selection::TextSurface;
12use crate::theme::Theme;
13use crate::views::tool_output::styled_tool_output_lines;
14use crate::views::tools::{tool_call_height, DisplayToolCall};
15
16#[derive(Debug)]
17pub struct ChatRenderData {
18 pub lines: Vec<Line<'static>>,
19 pub tool_line_indices: Vec<(usize, String)>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum MessageRole {
25 User,
26 Assistant,
27 System,
28 Warning,
29 Compaction,
30 Error,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DisplayAssistantBlock {
36 Text(String),
37 ThoughtDuration { seconds: u64 },
38 ToolCall { id: String },
39}
40
41#[derive(Debug, Clone)]
43pub struct DisplayMessage {
44 pub role: MessageRole,
45 pub content: String,
46 pub thinking: Option<String>,
47 pub tool_calls: Vec<DisplayToolCall>,
48 pub assistant_blocks: Vec<DisplayAssistantBlock>,
49 pub is_streaming: bool,
50 pub timestamp: u64,
51}
52
53impl DisplayMessage {
54 pub fn from_message(msg: &imp_llm::Message) -> Self {
56 match msg {
57 imp_llm::Message::User(u) => {
58 let text = u
59 .content
60 .iter()
61 .filter_map(|b| match b {
62 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
63 _ => None,
64 })
65 .collect::<Vec<_>>()
66 .join("");
67 Self {
68 role: MessageRole::User,
69 content: text,
70 thinking: None,
71 tool_calls: Vec::new(),
72 assistant_blocks: Vec::new(),
73 is_streaming: false,
74 timestamp: u.timestamp,
75 }
76 }
77 imp_llm::Message::Assistant(a) => {
78 let mut display = Self {
79 role: MessageRole::Assistant,
80 content: String::new(),
81 thinking: None,
82 tool_calls: Vec::new(),
83 assistant_blocks: Vec::new(),
84 is_streaming: false,
85 timestamp: a.timestamp,
86 };
87 for block in &a.content {
88 match block {
89 imp_llm::ContentBlock::Text { text: t } => {
90 display.add_assistant_text_block(t);
91 }
92 imp_llm::ContentBlock::Thinking { text: t } => {
93 match &mut display.thinking {
94 Some(existing) => existing.push_str(t),
95 None => display.thinking = Some(t.clone()),
96 }
97 }
98 imp_llm::ContentBlock::ToolCall {
99 id,
100 name,
101 arguments,
102 } => {
103 display.push_assistant_tool_call(DisplayToolCall {
104 id: id.clone(),
105 name: name.clone(),
106 args_summary: DisplayToolCall::make_args_summary(name, arguments),
107 output: None,
108 details: arguments.clone(),
109 is_error: false,
110 expanded: false,
111 streaming_lines: Vec::new(),
112 streaming_output: String::new(),
113 });
114 }
115 _ => {}
116 }
117 }
118 display
119 }
120 imp_llm::Message::ToolResult(t) => {
121 let text = t
122 .content
123 .iter()
124 .filter_map(|b| match b {
125 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
126 _ => None,
127 })
128 .collect::<Vec<_>>()
129 .join("");
130 Self {
131 role: if t.is_error {
132 MessageRole::Error
133 } else {
134 MessageRole::System
135 },
136 content: text,
137 thinking: None,
138 tool_calls: Vec::new(),
139 assistant_blocks: Vec::new(),
140 is_streaming: false,
141 timestamp: t.timestamp,
142 }
143 }
144 }
145 }
146
147 pub fn add_assistant_text_block(&mut self, text: &str) {
148 if text.is_empty() {
149 return;
150 }
151
152 self.content.push_str(text);
153 if let Some(DisplayAssistantBlock::Text(existing)) = self.assistant_blocks.last_mut() {
154 existing.push_str(text);
155 } else {
156 self.assistant_blocks
157 .push(DisplayAssistantBlock::Text(text.to_string()));
158 }
159 }
160
161 pub fn push_assistant_text_delta(&mut self, text: &str) {
162 self.add_assistant_text_block(text);
163 }
164
165 pub fn push_assistant_thought_duration(&mut self, seconds: u64) {
166 let seconds = seconds.max(1);
167 if matches!(
168 self.assistant_blocks.last(),
169 Some(DisplayAssistantBlock::ThoughtDuration { .. })
170 ) {
171 return;
172 }
173 self.assistant_blocks
174 .push(DisplayAssistantBlock::ThoughtDuration { seconds });
175 }
176
177 pub fn push_assistant_tool_call(&mut self, tool_call: DisplayToolCall) {
178 let id = tool_call.id.clone();
179 self.tool_calls.push(tool_call);
180 self.assistant_blocks
181 .push(DisplayAssistantBlock::ToolCall { id });
182 }
183
184 fn find_tool_call(&self, id: &str) -> Option<&DisplayToolCall> {
185 self.tool_calls.iter().find(|tc| tc.id == id)
186 }
187
188 pub fn line_count(&self, theme: &Theme, highlighter: &Highlighter) -> usize {
190 let mut count = 0;
191
192 count += 1;
194
195 if !self.content.is_empty() {
197 match self.role {
198 MessageRole::Assistant => {
199 count += markdown::render_markdown(&self.content, theme, highlighter).len();
200 }
201 _ => {
202 count += self.content.lines().count().max(1);
203 }
204 }
205 }
206
207 if self.thinking.is_some() {
209 count += 1; }
211
212 for tc in &self.tool_calls {
214 count += tool_call_height(tc) as usize;
215 }
216
217 count += 1;
219 count
220 }
221}
222
223const PASTED_SUMMARY_MIN_LINES: usize = 3;
224const PASTED_SUMMARY_MIN_CODE_LIKE_LINES: usize = 3;
225
226pub fn summarize_user_text_for_display(text: &str) -> String {
227 pasted_block_summary(text).unwrap_or_else(|| text.to_string())
228}
229
230pub fn pasted_block_summary(text: &str) -> Option<String> {
231 let line_count = text.lines().count();
232 if line_count < PASTED_SUMMARY_MIN_LINES {
233 return None;
234 }
235
236 let code_like_lines = text.lines().filter(|line| is_code_like_line(line)).count();
237 if code_like_lines < PASTED_SUMMARY_MIN_CODE_LIKE_LINES {
238 return None;
239 }
240
241 Some(format!(
242 "[Pasted {line_count} {}]",
243 if line_count == 1 { "Line" } else { "Lines" }
244 ))
245}
246
247fn is_code_like_line(line: &str) -> bool {
248 let trimmed = line.trim();
249 if trimmed.is_empty() {
250 return false;
251 }
252
253 if trimmed.starts_with("```") {
254 return true;
255 }
256
257 if line.starts_with(' ') || line.starts_with('\t') {
258 return true;
259 }
260
261 if trimmed.ends_with('{')
262 || trimmed.ends_with('}')
263 || trimmed.ends_with(';')
264 || trimmed.ends_with(",")
265 || trimmed.ends_with(")")
266 || trimmed.ends_with("]")
267 {
268 return true;
269 }
270
271 [
272 "fn ",
273 "let ",
274 "const ",
275 "pub ",
276 "impl ",
277 "use ",
278 "mod ",
279 "struct ",
280 "enum ",
281 "trait ",
282 "async ",
283 "await ",
284 "return ",
285 "if ",
286 "else",
287 "match ",
288 "for ",
289 "while ",
290 "loop ",
291 "class ",
292 "def ",
293 "import ",
294 "from ",
295 "function ",
296 "interface ",
297 "type ",
298 "SELECT ",
299 "INSERT ",
300 "UPDATE ",
301 "DELETE ",
302 "CREATE ",
303 "ALTER ",
304 ]
305 .iter()
306 .any(|prefix| trimmed.starts_with(prefix))
307 || trimmed.contains("::")
308 || trimmed.contains("->")
309 || trimmed.contains("=>")
310 || trimmed.contains("</")
311 || trimmed.contains("/>")
312}
313
314pub struct ChatView<'a> {
316 messages: &'a [DisplayMessage],
317 theme: &'a Theme,
318 highlighter: &'a Highlighter,
319 precomputed_lines: Option<&'a [Line<'static>]>,
320 scroll_offset: usize,
321 tick: u64,
322 tool_focus: Option<usize>,
324 word_wrap: bool,
326 chat_tool_display: ChatToolDisplay,
328 thinking_lines: usize,
330 show_timestamps: bool,
332 animation_level: AnimationLevel,
333 activity_state: AnimationState,
334}
335
336impl<'a> ChatView<'a> {
337 pub fn new(
338 messages: &'a [DisplayMessage],
339 theme: &'a Theme,
340 highlighter: &'a Highlighter,
341 ) -> Self {
342 Self {
343 messages,
344 theme,
345 highlighter,
346 precomputed_lines: None,
347 scroll_offset: 0,
348 tick: 0,
349 tool_focus: None,
350 word_wrap: true,
351 chat_tool_display: ChatToolDisplay::Interleaved,
352 thinking_lines: 5,
353 show_timestamps: false,
354 animation_level: AnimationLevel::Minimal,
355 activity_state: AnimationState::Idle,
356 }
357 }
358
359 pub fn precomputed_lines(mut self, lines: &'a [Line<'static>]) -> Self {
360 self.precomputed_lines = Some(lines);
361 self
362 }
363
364 pub fn scroll(mut self, offset: usize) -> Self {
365 self.scroll_offset = offset;
366 self
367 }
368
369 pub fn tick(mut self, tick: u64) -> Self {
370 self.tick = tick;
371 self
372 }
373
374 pub fn tool_focus(mut self, focus: Option<usize>) -> Self {
375 self.tool_focus = focus;
376 self
377 }
378
379 pub fn word_wrap(mut self, enabled: bool) -> Self {
380 self.word_wrap = enabled;
381 self
382 }
383
384 pub fn chat_tool_display(mut self, display: ChatToolDisplay) -> Self {
385 self.chat_tool_display = display;
386 self
387 }
388
389 pub fn thinking_lines(mut self, lines: usize) -> Self {
390 self.thinking_lines = lines;
391 self
392 }
393
394 pub fn show_timestamps(mut self, show: bool) -> Self {
395 self.show_timestamps = show;
396 self
397 }
398
399 pub fn animation_level(mut self, level: AnimationLevel) -> Self {
400 self.animation_level = level;
401 self
402 }
403
404 pub fn activity_state(mut self, state: AnimationState) -> Self {
405 self.activity_state = state;
406 self
407 }
408}
409
410pub struct RenderedChatView<'a> {
411 lines: &'a [Line<'static>],
412 scroll_offset: usize,
413}
414
415impl<'a> RenderedChatView<'a> {
416 pub fn new(lines: &'a [Line<'static>]) -> Self {
417 Self {
418 lines,
419 scroll_offset: 0,
420 }
421 }
422
423 pub fn scroll(mut self, offset: usize) -> Self {
424 self.scroll_offset = offset;
425 self
426 }
427}
428
429impl Widget for RenderedChatView<'_> {
430 fn render(self, area: Rect, buf: &mut Buffer) {
431 if area.height == 0 || area.width == 0 {
432 return;
433 }
434
435 render_visible_lines(self.lines, area, buf, self.scroll_offset);
436 }
437}
438
439impl Widget for ChatView<'_> {
440 fn render(self, area: Rect, buf: &mut Buffer) {
441 if area.height == 0 || area.width == 0 {
442 return;
443 }
444
445 if let Some(lines) = self.precomputed_lines {
446 render_visible_lines(lines, area, buf, self.scroll_offset);
447 return;
448 }
449
450 let (all_lines, _) = build_chat_lines(
451 self.messages,
452 self.theme,
453 self.highlighter,
454 area.width as usize,
455 self.tick,
456 self.tool_focus,
457 self.word_wrap,
458 self.chat_tool_display,
459 self.thinking_lines,
460 self.show_timestamps,
461 self.animation_level,
462 self.activity_state,
463 );
464
465 render_visible_lines(&all_lines, area, buf, self.scroll_offset);
466 }
467}
468
469fn render_visible_lines(lines: &[Line<'_>], area: Rect, buf: &mut Buffer, scroll_offset: usize) {
470 let window = visible_line_window(lines.len(), area.height as usize, scroll_offset);
471 let visible = &lines[window.start..window.end];
472
473 for (i, line) in visible.iter().enumerate() {
474 let y = area.y + i as u16;
475 if y >= area.y + area.height {
476 break;
477 }
478 buf.set_line(area.x, y, line, area.width);
479 }
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483struct VisibleLineWindow {
484 scroll_offset: usize,
485 start: usize,
486 end: usize,
487}
488
489fn clamp_scroll_offset_to_view(
490 total_lines: usize,
491 visible_height: usize,
492 scroll_offset: usize,
493) -> usize {
494 scroll_offset.min(total_lines.saturating_sub(visible_height))
495}
496
497fn visible_line_window(
498 total_lines: usize,
499 visible_height: usize,
500 scroll_offset: usize,
501) -> VisibleLineWindow {
502 let scroll_offset = clamp_scroll_offset_to_view(total_lines, visible_height, scroll_offset);
503 let start = total_lines.saturating_sub(visible_height + scroll_offset);
504 let end = total_lines.min(start + visible_height);
505
506 VisibleLineWindow {
507 scroll_offset,
508 start,
509 end,
510 }
511}
512
513pub fn clamped_scroll_offset_for_total_lines(
514 total_lines: usize,
515 chat_area: Rect,
516 scroll_offset: usize,
517) -> usize {
518 clamp_scroll_offset_to_view(total_lines, chat_area.height as usize, scroll_offset)
519}
520
521#[allow(clippy::too_many_arguments)]
522pub fn scroll_offset_for_message_at_top(
523 messages: &[DisplayMessage],
524 theme: &Theme,
525 highlighter: &Highlighter,
526 chat_area: Rect,
527 message_index: usize,
528 tick: u64,
529 tool_focus: Option<usize>,
530 word_wrap: bool,
531 chat_tool_display: ChatToolDisplay,
532 thinking_lines: usize,
533 show_timestamps: bool,
534 animation_level: AnimationLevel,
535 activity_state: AnimationState,
536) -> usize {
537 if message_index >= messages.len() {
538 return 0;
539 }
540
541 let total_lines = build_chat_render_data(
542 messages,
543 theme,
544 highlighter,
545 chat_area.width as usize,
546 tick,
547 tool_focus,
548 word_wrap,
549 chat_tool_display,
550 thinking_lines,
551 show_timestamps,
552 animation_level,
553 activity_state,
554 )
555 .lines
556 .len();
557 let anchor_line = build_chat_render_data(
558 &messages[..message_index],
559 theme,
560 highlighter,
561 chat_area.width as usize,
562 tick,
563 tool_focus,
564 word_wrap,
565 chat_tool_display,
566 thinking_lines,
567 show_timestamps,
568 animation_level,
569 activity_state,
570 )
571 .lines
572 .len();
573
574 let visible_height = chat_area.height as usize;
575 let offset = total_lines.saturating_sub(visible_height + anchor_line);
576 clamp_scroll_offset_to_view(total_lines, visible_height, offset)
577}
578
579#[allow(clippy::too_many_arguments)]
580pub fn clamped_scroll_offset(
581 messages: &[DisplayMessage],
582 theme: &Theme,
583 highlighter: &Highlighter,
584 chat_area: Rect,
585 scroll_offset: usize,
586 tick: u64,
587 tool_focus: Option<usize>,
588 word_wrap: bool,
589 chat_tool_display: ChatToolDisplay,
590 thinking_lines: usize,
591 show_timestamps: bool,
592 animation_level: AnimationLevel,
593 activity_state: AnimationState,
594) -> usize {
595 let render = build_chat_render_data(
596 messages,
597 theme,
598 highlighter,
599 chat_area.width as usize,
600 tick,
601 tool_focus,
602 word_wrap,
603 chat_tool_display,
604 thinking_lines,
605 show_timestamps,
606 animation_level,
607 activity_state,
608 );
609
610 clamped_scroll_offset_for_total_lines(render.lines.len(), chat_area, scroll_offset)
611}
612
613#[allow(clippy::too_many_arguments)]
614pub fn build_chat_render_data(
615 messages: &[DisplayMessage],
616 theme: &Theme,
617 highlighter: &Highlighter,
618 width: usize,
619 tick: u64,
620 tool_focus: Option<usize>,
621 word_wrap: bool,
622 chat_tool_display: ChatToolDisplay,
623 thinking_lines: usize,
624 show_timestamps: bool,
625 animation_level: AnimationLevel,
626 activity_state: AnimationState,
627) -> ChatRenderData {
628 let (lines, tool_line_indices) = build_chat_lines(
629 messages,
630 theme,
631 highlighter,
632 width,
633 tick,
634 tool_focus,
635 word_wrap,
636 chat_tool_display,
637 thinking_lines,
638 show_timestamps,
639 animation_level,
640 activity_state,
641 );
642
643 ChatRenderData {
644 lines,
645 tool_line_indices,
646 }
647}
648
649#[allow(clippy::too_many_arguments)]
650fn build_chat_lines(
651 messages: &[DisplayMessage],
652 theme: &Theme,
653 highlighter: &Highlighter,
654 width: usize,
655 tick: u64,
656 tool_focus: Option<usize>,
657 word_wrap: bool,
658 chat_tool_display: ChatToolDisplay,
659 thinking_lines: usize,
660 show_timestamps: bool,
661 animation_level: AnimationLevel,
662 activity_state: AnimationState,
663) -> (Vec<Line<'static>>, Vec<(usize, String)>) {
664 let mut all_lines: Vec<Line<'static>> = Vec::new();
665 let mut tool_line_indices: Vec<(usize, String)> = Vec::new();
666 let mut tool_call_counter: usize = 0;
667
668 for msg in messages {
669 if show_timestamps {
670 all_lines.push(Line::from(Span::styled(
671 format!(" [{}]", format_timestamp(msg.timestamp)),
672 theme.muted_style(),
673 )));
674 }
675
676 match msg.role {
677 MessageRole::User => {
678 let content_style = Style::default().fg(theme.user_prefix);
679 let prefix_style = Style::default()
680 .fg(theme.user_prefix)
681 .add_modifier(Modifier::BOLD);
682 let logical_lines: Vec<&str> = if msg.content.is_empty() {
683 vec![""]
684 } else {
685 msg.content.lines().collect()
686 };
687
688 for (idx, raw_line) in logical_lines.iter().enumerate() {
689 let prefix = if idx == 0 {
690 vec![Span::styled("❯ ".to_string(), prefix_style)]
691 } else {
692 vec![Span::styled(" ".to_string(), content_style)]
693 };
694 let continuation = vec![Span::styled(" ".to_string(), content_style)];
695 all_lines.extend(wrap_text_with_prefix(
696 raw_line,
697 &prefix,
698 &continuation,
699 content_style,
700 width,
701 word_wrap,
702 ));
703 }
704 }
705 MessageRole::Assistant => {
706 if let Some(ref thinking) = msg.thinking {
707 if !thinking.is_empty() && thinking_lines > 0 {
708 let lines: Vec<&str> = thinking.lines().collect();
709 let total = lines.len();
710 let tail = if total > thinking_lines {
711 &lines[total - thinking_lines..]
712 } else {
713 &lines[..]
714 };
715 for (i, line) in tail.iter().enumerate() {
716 let prefix = if i == 0 && total > thinking_lines {
717 "💭"
718 } else {
719 " "
720 };
721 all_lines.extend(wrap_text_with_prefix(
722 &format!(" {prefix} {line}"),
723 &[],
724 &[],
725 theme.muted_style(),
726 width,
727 word_wrap,
728 ));
729 }
730 }
731 }
732
733 if !msg.assistant_blocks.is_empty() {
734 for block in &msg.assistant_blocks {
735 match block {
736 DisplayAssistantBlock::Text(text) => {
737 if !text.is_empty() {
738 let rendered = markdown::render_markdown_with_width(
739 text,
740 theme,
741 highlighter,
742 width.saturating_sub(2),
743 );
744 let indent = vec![Span::raw(" ".to_string())];
745 for line in rendered {
746 all_lines.extend(wrap_line_with_prefix(
747 &line, &indent, &indent, width, word_wrap,
748 ));
749 }
750 }
751 }
752 DisplayAssistantBlock::ThoughtDuration { seconds } => {
753 all_lines.extend(wrap_text_with_prefix(
754 &format!(" thought for {}", format_duration_seconds(*seconds)),
755 &[],
756 &[],
757 theme.muted_style(),
758 width,
759 word_wrap,
760 ));
761 }
762 DisplayAssistantBlock::ToolCall { id } => {
763 let focused = tool_focus == Some(tool_call_counter);
764 tool_call_counter += 1;
765 if let Some(tc) = msg.find_tool_call(id) {
766 push_tool_call_chat_lines(
767 &mut all_lines,
768 &mut tool_line_indices,
769 highlighter,
770 tc,
771 theme,
772 tick,
773 width,
774 word_wrap,
775 focused,
776 chat_tool_display,
777 animation_level,
778 );
779 }
780 }
781 }
782 }
783 } else {
784 if !msg.content.is_empty() {
785 let rendered = markdown::render_markdown_with_width(
786 &msg.content,
787 theme,
788 highlighter,
789 width.saturating_sub(2),
790 );
791 let indent = vec![Span::raw(" ".to_string())];
792 for line in rendered {
793 all_lines.extend(wrap_line_with_prefix(
794 &line, &indent, &indent, width, word_wrap,
795 ));
796 }
797 }
798 for tc in &msg.tool_calls {
799 let focused = tool_focus == Some(tool_call_counter);
800 tool_call_counter += 1;
801 push_tool_call_chat_lines(
802 &mut all_lines,
803 &mut tool_line_indices,
804 highlighter,
805 tc,
806 theme,
807 tick,
808 width,
809 word_wrap,
810 focused,
811 chat_tool_display,
812 animation_level,
813 );
814 }
815 }
816
817 if msg.is_streaming && msg.content.trim().is_empty() {
818 let label = activity_label(
819 activity_state,
820 tick,
821 animation_level,
822 ActivitySurface::Chat,
823 );
824 if !label.is_empty() {
825 all_lines.extend(wrap_text_with_prefix(
826 &format!(" {label}"),
827 &[],
828 &[],
829 theme.accent_style(),
830 width,
831 word_wrap,
832 ));
833 }
834 }
835 }
836 MessageRole::System => {
837 for line in msg.content.lines() {
838 all_lines.extend(wrap_text_with_prefix(
839 &format!(" {line}"),
840 &[],
841 &[],
842 theme.muted_style(),
843 width,
844 word_wrap,
845 ));
846 }
847 }
848 MessageRole::Warning => {
849 for line in msg.content.lines() {
850 all_lines.extend(wrap_text_with_prefix(
851 &format!("Warning: {line}"),
852 &[],
853 &[],
854 theme.warning_style(),
855 width,
856 word_wrap,
857 ));
858 }
859 }
860 MessageRole::Compaction => {
861 all_lines.extend(wrap_text_with_prefix(
862 &format!(" [context compacted] {}", msg.content),
863 &[],
864 &[],
865 theme.muted_style(),
866 width,
867 word_wrap,
868 ));
869 }
870 MessageRole::Error => {
871 all_lines.extend(wrap_text_with_prefix(
872 &format!("Error: {}", msg.content),
873 &[],
874 &[],
875 theme.error_style(),
876 width,
877 word_wrap,
878 ));
879 }
880 }
881
882 all_lines.push(Line::raw(""));
883 }
884
885 (all_lines, tool_line_indices)
886}
887
888fn format_duration_seconds(seconds: u64) -> String {
889 match seconds {
890 0 | 1 => "1 second".to_string(),
891 2..=59 => format!("{seconds} seconds"),
892 _ => {
893 let minutes = seconds / 60;
894 let remaining_seconds = seconds % 60;
895 if remaining_seconds == 0 {
896 format!("{minutes}m")
897 } else {
898 format!("{minutes}m {remaining_seconds}s")
899 }
900 }
901 }
902}
903
904#[allow(clippy::too_many_arguments)]
905fn push_tool_call_chat_lines(
906 all_lines: &mut Vec<Line<'static>>,
907 tool_line_indices: &mut Vec<(usize, String)>,
908 highlighter: &Highlighter,
909 tc: &DisplayToolCall,
910 theme: &Theme,
911 tick: u64,
912 width: usize,
913 word_wrap: bool,
914 focused: bool,
915 chat_tool_display: ChatToolDisplay,
916 animation_level: AnimationLevel,
917) {
918 if chat_tool_display == ChatToolDisplay::Hidden {
919 return;
920 }
921
922 let is_running = tc.output.is_none() && !tc.is_error;
923 let rail = vec![Span::styled(" ".to_string(), theme.muted_style())];
924 let header = tc.header_line_animated_focused(theme, tick, focused, animation_level);
925 let header_lines = wrap_line_with_prefix(&header, &rail, &rail, width, word_wrap);
926 let header_start = all_lines.len();
927 for offset in 0..header_lines.len() {
928 tool_line_indices.push((header_start + offset, tc.id.clone()));
929 }
930 all_lines.extend(header_lines);
931
932 if chat_tool_display == ChatToolDisplay::Summary {
933 return;
934 }
935
936 if is_running && !tc.streaming_lines.is_empty() {
937 for line in &tc.streaming_lines {
938 let content = Line::from(Span::styled(format!(" {line}"), theme.muted_style()));
939 let line_start = all_lines.len();
940 let wrapped = wrap_line_with_prefix(&content, &rail, &rail, width, word_wrap);
941 for offset in 0..wrapped.len() {
942 tool_line_indices.push((line_start + offset, tc.id.clone()));
943 }
944 all_lines.extend(wrapped);
945 }
946 }
947
948 if tc.expanded {
949 let output_lines = styled_tool_output_lines(tc, highlighter, theme, tc.name == "read");
950 for line in output_lines.into_iter().take(50) {
951 let line_start = all_lines.len();
952 let wrapped = wrap_line_with_prefix(&line, &rail, &rail, width, word_wrap);
953 for offset in 0..wrapped.len() {
954 tool_line_indices.push((line_start + offset, tc.id.clone()));
955 }
956 all_lines.extend(wrapped);
957 }
958 }
959}
960
961fn wrap_text_with_prefix(
962 text: &str,
963 first_prefix: &[Span<'_>],
964 continuation_prefix: &[Span<'_>],
965 style: Style,
966 width: usize,
967 enabled: bool,
968) -> Vec<Line<'static>> {
969 let content = Line::from(Span::styled(text.to_string(), style));
970 wrap_line_with_prefix(&content, first_prefix, continuation_prefix, width, enabled)
971}
972
973fn wrap_line_with_prefix(
974 line: &Line<'_>,
975 first_prefix: &[Span<'_>],
976 continuation_prefix: &[Span<'_>],
977 width: usize,
978 enabled: bool,
979) -> Vec<Line<'static>> {
980 let first_prefix_owned = clone_spans(first_prefix);
981 let continuation_prefix_owned = clone_spans(continuation_prefix);
982
983 if !enabled || width == 0 {
984 let mut spans = first_prefix_owned;
985 spans.extend(clone_spans(&line.spans));
986 return vec![Line::from(spans)];
987 }
988
989 let chars = flatten_line_chars(line);
990 if chars.is_empty() {
991 return vec![Line::from(first_prefix_owned)];
992 }
993
994 let first_width = width.saturating_sub(spans_width(first_prefix));
995 let continuation_width = width.saturating_sub(spans_width(continuation_prefix));
996 let chunks = wrap_styled_chars(&chars, first_width, continuation_width);
997
998 let mut lines = Vec::with_capacity(chunks.len());
999 for (idx, chunk) in chunks.into_iter().enumerate() {
1000 let mut spans = if idx == 0 {
1001 clone_spans(&first_prefix_owned)
1002 } else {
1003 clone_spans(&continuation_prefix_owned)
1004 };
1005 spans.extend(chars_to_spans(&chunk));
1006 lines.push(Line::from(spans));
1007 }
1008
1009 lines
1010}
1011
1012fn clone_spans(spans: &[Span<'_>]) -> Vec<Span<'static>> {
1013 spans
1014 .iter()
1015 .map(|span| Span::styled(span.content.to_string(), span.style))
1016 .collect()
1017}
1018
1019fn spans_width(spans: &[Span<'_>]) -> usize {
1020 spans
1021 .iter()
1022 .map(|span| span.content.chars().count())
1023 .sum::<usize>()
1024}
1025
1026fn line_to_plain_text(line: &Line<'_>) -> String {
1027 line.spans
1028 .iter()
1029 .map(|span| span.content.as_ref())
1030 .collect()
1031}
1032
1033fn flatten_line_chars(line: &Line<'_>) -> Vec<(char, Style)> {
1034 let mut chars = Vec::new();
1035 for span in &line.spans {
1036 for ch in span.content.chars() {
1037 chars.push((ch, span.style));
1038 }
1039 }
1040 chars
1041}
1042
1043fn wrap_styled_chars(
1044 chars: &[(char, Style)],
1045 first_width: usize,
1046 continuation_width: usize,
1047) -> Vec<Vec<(char, Style)>> {
1048 let mut chunks = Vec::new();
1049 let mut start = 0;
1050 let mut current_width = first_width.max(1);
1051
1052 while start < chars.len() {
1053 let remaining = chars.len() - start;
1054 if remaining <= current_width {
1055 chunks.push(chars[start..].to_vec());
1056 break;
1057 }
1058
1059 let end = start + current_width;
1060 let break_at = (start + 1..end)
1061 .rev()
1062 .find(|&idx| chars[idx].0.is_whitespace());
1063
1064 if let Some(space_idx) = break_at {
1065 chunks.push(chars[start..space_idx].to_vec());
1066 start = space_idx + 1;
1067 while start < chars.len() && chars[start].0.is_whitespace() {
1068 start += 1;
1069 }
1070 } else {
1071 chunks.push(chars[start..end].to_vec());
1072 start = end;
1073 }
1074
1075 current_width = continuation_width.max(1);
1076 }
1077
1078 if chunks.is_empty() {
1079 chunks.push(Vec::new());
1080 }
1081
1082 chunks
1083}
1084
1085fn chars_to_spans(chars: &[(char, Style)]) -> Vec<Span<'static>> {
1086 if chars.is_empty() {
1087 return Vec::new();
1088 }
1089
1090 let mut spans = Vec::new();
1091 let mut current_style = chars[0].1;
1092 let mut current_text = String::new();
1093
1094 for (ch, style) in chars {
1095 if *style == current_style {
1096 current_text.push(*ch);
1097 } else {
1098 spans.push(Span::styled(current_text, current_style));
1099 current_text = ch.to_string();
1100 current_style = *style;
1101 }
1102 }
1103
1104 if !current_text.is_empty() {
1105 spans.push(Span::styled(current_text, current_style));
1106 }
1107
1108 spans
1109}
1110
1111pub fn total_rendered_lines(
1113 messages: &[DisplayMessage],
1114 theme: &Theme,
1115 highlighter: &Highlighter,
1116) -> usize {
1117 messages
1118 .iter()
1119 .map(|m| m.line_count(theme, highlighter))
1120 .sum()
1121}
1122
1123fn format_timestamp(ts: u64) -> String {
1124 let secs = ts % 86_400;
1125 let h = secs / 3_600;
1126 let m = (secs % 3_600) / 60;
1127 format!("{h:02}:{m:02}")
1128}
1129
1130pub fn build_text_surface_from_lines(
1131 lines: &[Line<'_>],
1132 chat_area: Rect,
1133 scroll_offset: usize,
1134) -> TextSurface {
1135 let lines: Vec<String> = lines.iter().map(line_to_plain_text).collect();
1136 let total_lines = lines.len();
1137 let start = visible_line_window(total_lines, chat_area.height as usize, scroll_offset).start;
1138
1139 TextSurface::new(
1140 crate::selection::SelectablePane::Chat,
1141 chat_area,
1142 lines,
1143 start,
1144 )
1145}
1146
1147#[allow(clippy::too_many_arguments)]
1148pub fn build_text_surface(
1149 messages: &[DisplayMessage],
1150 theme: &Theme,
1151 highlighter: &Highlighter,
1152 chat_area: Rect,
1153 scroll_offset: usize,
1154 tick: u64,
1155 tool_focus: Option<usize>,
1156 word_wrap: bool,
1157 chat_tool_display: ChatToolDisplay,
1158 thinking_lines: usize,
1159 show_timestamps: bool,
1160 animation_level: AnimationLevel,
1161 activity_state: AnimationState,
1162) -> TextSurface {
1163 let render = build_chat_render_data(
1164 messages,
1165 theme,
1166 highlighter,
1167 chat_area.width as usize,
1168 tick,
1169 tool_focus,
1170 word_wrap,
1171 chat_tool_display,
1172 thinking_lines,
1173 show_timestamps,
1174 animation_level,
1175 activity_state,
1176 );
1177
1178 build_text_surface_from_lines(&render.lines, chat_area, scroll_offset)
1179}
1180
1181pub fn build_click_map_from_rendered_lines(
1183 lines: &[Line<'_>],
1184 chat_area: Rect,
1185 scroll_offset: usize,
1186) -> Vec<(u16, String)> {
1187 let total_lines = lines.len();
1188 let window = visible_line_window(total_lines, chat_area.height as usize, scroll_offset);
1189 let mut result = Vec::new();
1190
1191 for (line_index, line) in lines.iter().enumerate().take(window.end).skip(window.start) {
1192 let plain = line_to_plain_text(line);
1193 let Some(rest) = plain
1194 .strip_prefix("▸ ")
1195 .or_else(|| plain.strip_prefix("▾ "))
1196 else {
1197 continue;
1198 };
1199 let Some(id) = rest.strip_prefix('#') else {
1200 continue;
1201 };
1202 let id = id.split_whitespace().next().unwrap_or_default();
1203 if !id.is_empty() {
1204 let screen_y = chat_area.y + (line_index - window.start) as u16;
1205 result.push((screen_y, id.to_string()));
1206 }
1207 }
1208
1209 result
1210}
1211
1212#[allow(clippy::too_many_arguments)]
1213pub fn build_click_map(
1214 messages: &[DisplayMessage],
1215 theme: &Theme,
1216 highlighter: &Highlighter,
1217 chat_area: Rect,
1218 scroll_offset: usize,
1219 word_wrap: bool,
1220 chat_tool_display: ChatToolDisplay,
1221 thinking_lines: usize,
1222 show_timestamps: bool,
1223) -> Vec<(u16, String)> {
1224 let (all_lines, tool_line_indices) = build_chat_lines(
1225 messages,
1226 theme,
1227 highlighter,
1228 chat_area.width as usize,
1229 0,
1230 None,
1231 word_wrap,
1232 chat_tool_display,
1233 thinking_lines,
1234 show_timestamps,
1235 AnimationLevel::Minimal,
1236 AnimationState::Idle,
1237 );
1238
1239 let window = visible_line_window(all_lines.len(), chat_area.height as usize, scroll_offset);
1240
1241 let mut result = Vec::new();
1242 for (line_index, id) in &tool_line_indices {
1243 if *line_index >= window.start && *line_index < window.end {
1244 let screen_y = chat_area.y + (*line_index - window.start) as u16;
1245 result.push((screen_y, id.clone()));
1246 }
1247 }
1248
1249 result
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255
1256 fn make_tool(id: &str) -> DisplayToolCall {
1257 DisplayToolCall {
1258 id: id.into(),
1259 name: "read".into(),
1260 args_summary: "src/main.rs".into(),
1261 output: Some("fn main() {}".into()),
1262 details: serde_json::json!({"path": "src/main.rs"}),
1263 is_error: false,
1264 expanded: false,
1265 streaming_lines: Vec::new(),
1266 streaming_output: String::new(),
1267 }
1268 }
1269
1270 fn line_text(line: &Line<'_>) -> String {
1271 line.spans
1272 .iter()
1273 .map(|span| span.content.as_ref())
1274 .collect()
1275 }
1276
1277 #[test]
1278 fn large_pasted_code_is_summarized_for_display() {
1279 let code = (1..=25)
1280 .map(|i| format!("fn example_{i}() {{}}"))
1281 .collect::<Vec<_>>()
1282 .join("\n");
1283
1284 assert_eq!(summarize_user_text_for_display(&code), "[Pasted 25 Lines]");
1285 }
1286
1287 #[test]
1288 fn ordinary_multiline_text_is_not_summarized() {
1289 let text = (1..=25)
1290 .map(|i| format!("This is regular prose line {i}"))
1291 .collect::<Vec<_>>()
1292 .join("\n");
1293
1294 assert_eq!(summarize_user_text_for_display(&text), text);
1295 }
1296
1297 #[test]
1298 fn short_code_block_is_not_summarized() {
1299 let code = (1..=2)
1300 .map(|i| format!("let value_{i} = {i};"))
1301 .collect::<Vec<_>>()
1302 .join("\n");
1303
1304 assert_eq!(summarize_user_text_for_display(&code), code);
1305 }
1306
1307 #[test]
1308 fn three_line_code_block_is_summarized() {
1309 let code = (1..=3)
1310 .map(|i| format!("let value_{i} = {i};"))
1311 .collect::<Vec<_>>()
1312 .join("\n");
1313
1314 assert_eq!(summarize_user_text_for_display(&code), "[Pasted 3 Lines]");
1315 }
1316
1317 #[test]
1318 fn wraps_long_user_message() {
1319 let theme = Theme::default();
1320 let highlighter = Highlighter::new();
1321 let messages = vec![DisplayMessage {
1322 role: MessageRole::User,
1323 content: "this is a long line that should wrap in the chat view".into(),
1324 thinking: None,
1325 tool_calls: Vec::new(),
1326 assistant_blocks: Vec::new(),
1327 is_streaming: false,
1328 timestamp: 0,
1329 }];
1330
1331 let (lines, _) = build_chat_lines(
1332 &messages,
1333 &theme,
1334 &highlighter,
1335 20,
1336 0,
1337 None,
1338 true,
1339 ChatToolDisplay::Interleaved,
1340 5,
1341 false,
1342 AnimationLevel::Minimal,
1343 AnimationState::Idle,
1344 );
1345
1346 assert!(lines.len() > 2, "expected wrapped content plus separator");
1347 }
1348
1349 #[test]
1350 fn hide_tools_in_chat_removes_tool_lines() {
1351 let theme = Theme::default();
1352 let highlighter = Highlighter::new();
1353 let messages = vec![DisplayMessage {
1354 role: MessageRole::Assistant,
1355 content: "done".into(),
1356 thinking: None,
1357 tool_calls: vec![make_tool("tc-1")],
1358 assistant_blocks: Vec::new(),
1359 is_streaming: false,
1360 timestamp: 0,
1361 }];
1362
1363 let (_, visible_tools) = build_chat_lines(
1364 &messages,
1365 &theme,
1366 &highlighter,
1367 80,
1368 0,
1369 None,
1370 true,
1371 ChatToolDisplay::Hidden,
1372 5,
1373 false,
1374 AnimationLevel::Minimal,
1375 AnimationState::Idle,
1376 );
1377
1378 assert!(visible_tools.is_empty());
1379 }
1380
1381 #[test]
1382 fn build_click_map_from_rendered_lines_finds_visible_tool_headers() {
1383 let lines = vec![
1384 Line::from("hello"),
1385 Line::from("▸ #tool-1 read src/main.rs"),
1386 Line::from("world"),
1387 ];
1388 let map = build_click_map_from_rendered_lines(&lines, Rect::new(0, 10, 80, 3), 0);
1389 assert_eq!(map, vec![(11, "tool-1".to_string())]);
1390 }
1391
1392 #[test]
1393 fn build_click_map_from_rendered_lines_respects_scroll_window() {
1394 let lines = vec![
1395 Line::from("before"),
1396 Line::from("▸ #tool-1 read src/main.rs"),
1397 Line::from("middle"),
1398 Line::from("▾ #tool-2 bash cargo test"),
1399 ];
1400 let map = build_click_map_from_rendered_lines(&lines, Rect::new(0, 5, 80, 2), 0);
1401 assert_eq!(map, vec![(6, "tool-2".to_string())]);
1402 }
1403
1404 #[test]
1405 fn assistant_blocks_preserve_thought_duration_tool_thought_order() {
1406 let display = DisplayMessage {
1407 role: MessageRole::Assistant,
1408 content: String::new(),
1409 thinking: None,
1410 tool_calls: vec![make_tool("tc-1")],
1411 assistant_blocks: vec![
1412 DisplayAssistantBlock::ThoughtDuration { seconds: 5 },
1413 DisplayAssistantBlock::ToolCall { id: "tc-1".into() },
1414 DisplayAssistantBlock::ThoughtDuration { seconds: 20 },
1415 DisplayAssistantBlock::Text("Done".into()),
1416 ],
1417 is_streaming: false,
1418 timestamp: 0,
1419 };
1420
1421 let theme = Theme::default();
1422 let highlighter = Highlighter::new();
1423 let (lines, _) = build_chat_lines(
1424 &[display],
1425 &theme,
1426 &highlighter,
1427 80,
1428 0,
1429 None,
1430 true,
1431 ChatToolDisplay::Interleaved,
1432 5,
1433 false,
1434 AnimationLevel::Minimal,
1435 AnimationState::Idle,
1436 );
1437
1438 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1439 let first_thought_idx = rendered
1440 .iter()
1441 .position(|line| line.contains("thought for 5 seconds"))
1442 .unwrap();
1443 let tool_idx = rendered
1444 .iter()
1445 .position(|line| line.contains("read") && line.contains("src/main.rs"))
1446 .unwrap();
1447 let second_thought_idx = rendered
1448 .iter()
1449 .position(|line| line.contains("thought for 20 seconds"))
1450 .unwrap();
1451 let text_idx = rendered
1452 .iter()
1453 .position(|line| line.contains("Done"))
1454 .unwrap();
1455
1456 assert!(first_thought_idx < tool_idx);
1457 assert!(tool_idx < second_thought_idx);
1458 assert!(second_thought_idx < text_idx);
1459 }
1460
1461 #[test]
1462 fn assistant_blocks_preserve_text_tool_text_order() {
1463 let assistant = imp_llm::Message::Assistant(imp_llm::AssistantMessage {
1464 content: vec![
1465 imp_llm::ContentBlock::Text {
1466 text: "Before tool".into(),
1467 },
1468 imp_llm::ContentBlock::ToolCall {
1469 id: "tc-1".into(),
1470 name: "read".into(),
1471 arguments: serde_json::json!({"path": "src/main.rs"}),
1472 },
1473 imp_llm::ContentBlock::Text {
1474 text: "After tool".into(),
1475 },
1476 ],
1477 usage: None,
1478 stop_reason: imp_llm::StopReason::ToolUse,
1479 timestamp: 0,
1480 });
1481
1482 let display = DisplayMessage::from_message(&assistant);
1483 assert_eq!(
1484 display.assistant_blocks,
1485 vec![
1486 DisplayAssistantBlock::Text("Before tool".into()),
1487 DisplayAssistantBlock::ToolCall { id: "tc-1".into() },
1488 DisplayAssistantBlock::Text("After tool".into()),
1489 ]
1490 );
1491 }
1492
1493 #[test]
1494 fn interleaved_mode_renders_tool_between_text_blocks() {
1495 let theme = Theme::default();
1496 let highlighter = Highlighter::new();
1497 let messages = vec![DisplayMessage {
1498 role: MessageRole::Assistant,
1499 content: "Before toolAfter tool".into(),
1500 thinking: None,
1501 tool_calls: vec![make_tool("tc-1")],
1502 assistant_blocks: vec![
1503 DisplayAssistantBlock::Text("Before tool".into()),
1504 DisplayAssistantBlock::ToolCall { id: "tc-1".into() },
1505 DisplayAssistantBlock::Text("After tool".into()),
1506 ],
1507 is_streaming: false,
1508 timestamp: 0,
1509 }];
1510
1511 let (lines, _) = build_chat_lines(
1512 &messages,
1513 &theme,
1514 &highlighter,
1515 80,
1516 0,
1517 None,
1518 true,
1519 ChatToolDisplay::Interleaved,
1520 5,
1521 false,
1522 AnimationLevel::Minimal,
1523 AnimationState::Idle,
1524 );
1525
1526 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1527 let before_idx = rendered
1528 .iter()
1529 .position(|line| line.contains("Before tool"))
1530 .unwrap();
1531 let tool_idx = rendered
1532 .iter()
1533 .position(|line| line.contains("read") && line.contains("src/main.rs"))
1534 .unwrap();
1535 let after_idx = rendered
1536 .iter()
1537 .position(|line| line.contains("After tool"))
1538 .unwrap();
1539
1540 assert!(before_idx < tool_idx && tool_idx < after_idx);
1541 }
1542
1543 #[test]
1544 fn summary_mode_hides_tool_output_but_keeps_header() {
1545 let theme = Theme::default();
1546 let highlighter = Highlighter::new();
1547 let mut tool = make_tool("tc-1");
1548 tool.expanded = true;
1549 let messages = vec![DisplayMessage {
1550 role: MessageRole::Assistant,
1551 content: String::new(),
1552 thinking: None,
1553 tool_calls: vec![tool],
1554 assistant_blocks: vec![DisplayAssistantBlock::ToolCall { id: "tc-1".into() }],
1555 is_streaming: false,
1556 timestamp: 0,
1557 }];
1558
1559 let (lines, visible_tools) = build_chat_lines(
1560 &messages,
1561 &theme,
1562 &highlighter,
1563 80,
1564 0,
1565 None,
1566 true,
1567 ChatToolDisplay::Summary,
1568 5,
1569 false,
1570 AnimationLevel::Minimal,
1571 AnimationState::Idle,
1572 );
1573
1574 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1575 assert_eq!(visible_tools.len(), 1);
1576 assert!(rendered
1577 .iter()
1578 .any(|line| line.contains("read") && line.contains("src/main.rs")));
1579 assert!(!rendered.iter().any(|line| line.contains("fn main() {}")));
1580 }
1581
1582 #[test]
1583 fn focused_tool_call_shows_arrow_in_summary_mode() {
1584 let theme = Theme::default();
1585 let highlighter = Highlighter::new();
1586 let messages = vec![DisplayMessage {
1587 role: MessageRole::Assistant,
1588 content: String::new(),
1589 thinking: None,
1590 tool_calls: vec![make_tool("tc-1")],
1591 assistant_blocks: vec![DisplayAssistantBlock::ToolCall { id: "tc-1".into() }],
1592 is_streaming: false,
1593 timestamp: 0,
1594 }];
1595
1596 let (lines, visible_tools) = build_chat_lines(
1597 &messages,
1598 &theme,
1599 &highlighter,
1600 80,
1601 0,
1602 Some(0),
1603 true,
1604 ChatToolDisplay::Summary,
1605 5,
1606 false,
1607 AnimationLevel::Minimal,
1608 AnimationState::Idle,
1609 );
1610
1611 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1612 assert_eq!(visible_tools.len(), 1);
1613 assert!(rendered.iter().any(|line| line.contains("▸")
1614 && line.contains("read")
1615 && line.contains("src/main.rs")));
1616 }
1617
1618 #[test]
1619 fn streaming_placeholder_renders_waiting_in_chat() {
1620 let theme = Theme::default();
1621 let highlighter = Highlighter::new();
1622 let messages = vec![DisplayMessage {
1623 role: MessageRole::Assistant,
1624 content: String::new(),
1625 thinking: None,
1626 tool_calls: Vec::new(),
1627 assistant_blocks: Vec::new(),
1628 is_streaming: true,
1629 timestamp: 0,
1630 }];
1631
1632 let (lines, _) = build_chat_lines(
1633 &messages,
1634 &theme,
1635 &highlighter,
1636 80,
1637 0,
1638 None,
1639 true,
1640 ChatToolDisplay::Interleaved,
1641 5,
1642 false,
1643 AnimationLevel::Minimal,
1644 AnimationState::WaitingForResponse,
1645 );
1646
1647 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1648 assert!(rendered.iter().any(|line| line.contains("waiting")));
1649 }
1650
1651 #[test]
1652 fn streaming_placeholder_renders_responding_in_chat() {
1653 let theme = Theme::default();
1654 let highlighter = Highlighter::new();
1655 let messages = vec![DisplayMessage {
1656 role: MessageRole::Assistant,
1657 content: String::new(),
1658 thinking: None,
1659 tool_calls: Vec::new(),
1660 assistant_blocks: Vec::new(),
1661 is_streaming: true,
1662 timestamp: 0,
1663 }];
1664
1665 let (lines, _) = build_chat_lines(
1666 &messages,
1667 &theme,
1668 &highlighter,
1669 80,
1670 0,
1671 None,
1672 true,
1673 ChatToolDisplay::Interleaved,
1674 5,
1675 false,
1676 AnimationLevel::Minimal,
1677 AnimationState::Streaming,
1678 );
1679
1680 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1681 assert!(rendered.iter().any(|line| line.contains("responding")));
1682 }
1683
1684 #[test]
1685 fn warning_messages_render_with_prefix() {
1686 let theme = Theme::default();
1687 let highlighter = Highlighter::new();
1688 let messages = vec![DisplayMessage {
1689 role: MessageRole::Warning,
1690 content: "line 1\nline 2".into(),
1691 thinking: None,
1692 tool_calls: Vec::new(),
1693 assistant_blocks: Vec::new(),
1694 is_streaming: false,
1695 timestamp: 0,
1696 }];
1697
1698 let (lines, _) = build_chat_lines(
1699 &messages,
1700 &theme,
1701 &highlighter,
1702 80,
1703 0,
1704 None,
1705 true,
1706 ChatToolDisplay::Interleaved,
1707 5,
1708 false,
1709 AnimationLevel::Minimal,
1710 AnimationState::Idle,
1711 );
1712
1713 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1714 assert!(rendered.iter().any(|line| line.contains("Warning: line 1")));
1715 assert!(rendered.iter().any(|line| line.contains("Warning: line 2")));
1716 }
1717
1718 #[test]
1719 fn system_messages_render_all_lines() {
1720 let theme = Theme::default();
1721 let highlighter = Highlighter::new();
1722 let messages = vec![DisplayMessage {
1723 role: MessageRole::System,
1724 content: "line 1\nline 2\nline 3\nline 4".into(),
1725 thinking: None,
1726 tool_calls: Vec::new(),
1727 assistant_blocks: Vec::new(),
1728 is_streaming: false,
1729 timestamp: 0,
1730 }];
1731
1732 let (lines, _) = build_chat_lines(
1733 &messages,
1734 &theme,
1735 &highlighter,
1736 80,
1737 0,
1738 None,
1739 true,
1740 ChatToolDisplay::Interleaved,
1741 5,
1742 false,
1743 AnimationLevel::Minimal,
1744 AnimationState::Idle,
1745 );
1746
1747 let rendered: Vec<String> = lines.iter().map(line_text).collect();
1748 assert!(rendered.iter().any(|line| line.contains("line 1")));
1749 assert!(rendered.iter().any(|line| line.contains("line 2")));
1750 assert!(rendered.iter().any(|line| line.contains("line 3")));
1751 assert!(rendered.iter().any(|line| line.contains("line 4")));
1752 }
1753}