Skip to main content

imp_tui/views/
chat.rs

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/// Role of a display message.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum MessageRole {
25    User,
26    Assistant,
27    System,
28    Warning,
29    Compaction,
30    Error,
31}
32
33/// Ordered display blocks inside an assistant message.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DisplayAssistantBlock {
36    Text(String),
37    ThoughtDuration { seconds: u64 },
38    ToolCall { id: String },
39}
40
41/// A message formatted for display in the chat view.
42#[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    /// Construct from an imp_llm Message.
55    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    /// Calculate the rendered line count for this message.
189    pub fn line_count(&self, theme: &Theme, highlighter: &Highlighter) -> usize {
190        let mut count = 0;
191
192        // Prefix line
193        count += 1;
194
195        // Content lines (markdown renders to lines)
196        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        // Thinking block
208        if self.thinking.is_some() {
209            count += 1; // header
210        }
211
212        // Tool calls
213        for tc in &self.tool_calls {
214            count += tool_call_height(tc) as usize;
215        }
216
217        // Separator
218        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
314/// Chat view: displays conversation messages with streaming support.
315pub 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    /// Flat index of the focused tool call across all messages, if any.
323    tool_focus: Option<usize>,
324    /// Word-wrap long chat lines to the current viewport width.
325    word_wrap: bool,
326    /// How tool calls should appear in the chat transcript.
327    chat_tool_display: ChatToolDisplay,
328    /// Number of thinking lines to show.
329    thinking_lines: usize,
330    /// Whether to show timestamps above messages.
331    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
1111/// Calculate the total number of rendered lines across all messages.
1112pub 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
1181/// Build a click map from already-rendered chat lines.
1182pub 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}