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