1use imp_core::config::{AnimationLevel, ChatToolDisplay};
2use ratatui::buffer::Buffer;
3use ratatui::layout::Rect;
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::Widget;
7
8use crate::animation::{activity_label, ActivitySurface, AnimationState};
9use crate::highlight::Highlighter;
10use crate::markdown;
11use crate::selection::TextSurface;
12use crate::theme::Theme;
13use crate::views::tool_output::styled_tool_output_lines;
14use crate::views::tools::{tool_call_height, DisplayToolCall};
15
16#[derive(Debug)]
17pub struct ChatRenderData {
18 pub lines: Vec<Line<'static>>,
19 pub tool_line_indices: Vec<(usize, String)>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum MessageRole {
25 User,
26 Assistant,
27 System,
28 Warning,
29 Compaction,
30 Error,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum DisplayAssistantBlock {
36 Text(String),
37 ToolCall { id: String },
38}
39
40#[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 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 pub fn line_count(&self, theme: &Theme, highlighter: &Highlighter) -> usize {
177 let mut count = 0;
178
179 count += 1;
181
182 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 if self.thinking.is_some() {
196 count += 1; }
198
199 for tc in &self.tool_calls {
201 count += tool_call_height(tc) as usize;
202 }
203
204 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
301pub 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 tool_focus: Option<usize>,
311 word_wrap: bool,
313 chat_tool_display: ChatToolDisplay,
315 thinking_lines: usize,
317 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
1006pub 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
1025pub 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}