Skip to main content

mermaid_cli/render/widgets/
chat.rs

1use std::hash::{Hash, Hasher};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::Rect,
6    style::Style,
7    text::{Line, Span},
8    widgets::{Block, Paragraph, StatefulWidget, Widget},
9};
10use rustc_hash::FxHashMap;
11use unicode_width::UnicodeWidthStr;
12
13use crate::domain::{ActionDetails, ActionDisplay, ActionResult, format_compact_count};
14use crate::models::ChatMessageKind;
15use crate::models::{ChatMessage, MessageRole};
16use crate::render::diff::{DiffLineKind, parse_diff_line};
17use crate::render::markdown::parse_markdown;
18use crate::render::theme::Theme;
19use crate::utils::format_relative_timestamp;
20
21/// Entry in the click map: maps a content line to an image in chat history
22#[derive(Debug, Clone)]
23pub struct ImageClickTarget {
24    /// Index into session_state.messages
25    pub message_index: usize,
26    /// Index into that message's images vec
27    pub image_index: usize,
28}
29
30/// State for the chat widget
31#[derive(Debug, Clone)]
32pub struct ChatState {
33    /// Manual scroll offset (only used when is_user_scrolling = true)
34    scroll_offset: u16,
35    /// Whether user is manually scrolling (not following bottom)
36    is_user_scrolling: bool,
37    /// Click map: content line number → image target (rebuilt every render)
38    pub image_click_map: Vec<(u16, ImageClickTarget)>,
39    /// Scroll position used in last render (for coordinate mapping)
40    pub last_scroll_position: u16,
41    /// Chat area rect from last render
42    pub last_chat_area: Option<(u16, u16, u16, u16)>, // (x, y, width, height)
43}
44
45impl ChatState {
46    /// Create a new chat state (starts in auto-follow mode)
47    pub fn new() -> Self {
48        Self {
49            scroll_offset: 0,
50            is_user_scrolling: false,
51            image_click_map: Vec::new(),
52            last_scroll_position: 0,
53            last_chat_area: None,
54        }
55    }
56
57    /// Get the scroll position for rendering
58    /// scroll_offset represents distance from bottom, convert to ratatui scroll position
59    pub fn get_scroll_position(&self, content_height: u16, viewport_height: u16) -> u16 {
60        let max_scroll = content_height.saturating_sub(viewport_height);
61        if self.is_user_scrolling {
62            // Manual scroll: convert "distance from bottom" to scroll position
63            // scroll_offset=0 → show bottom (max_scroll), scroll_offset=max → show top (0)
64            let capped_offset = self.scroll_offset.min(max_scroll);
65            max_scroll.saturating_sub(capped_offset)
66        } else {
67            // Auto-scroll: show bottom of content
68            max_scroll
69        }
70    }
71
72    /// Scroll viewport up (shows older messages further from bottom)
73    pub fn scroll_up(&mut self, amount: u16) {
74        self.is_user_scrolling = true;
75        self.scroll_offset = self.scroll_offset.saturating_add(amount);
76    }
77
78    /// Scroll viewport down (shows newer messages closer to bottom)
79    /// Automatically resumes auto-scroll when reaching the bottom
80    pub fn scroll_down(&mut self, amount: u16) {
81        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
82        if self.scroll_offset == 0 {
83            // Reached bottom — resume auto-follow mode
84            self.is_user_scrolling = false;
85        }
86    }
87
88    /// Force resume auto-scroll mode (jump to bottom)
89    pub fn resume_auto_scroll(&mut self) {
90        self.is_user_scrolling = false;
91        self.scroll_offset = 0;
92    }
93
94    /// Check if user is manually scrolling (not following bottom)
95    pub fn is_manually_scrolling(&self) -> bool {
96        self.is_user_scrolling
97    }
98
99    /// Find an image click target at the given screen coordinates.
100    /// Returns Some((message_index, image_index)) if an image indicator was clicked.
101    pub fn find_image_at_screen_pos(&self, screen_row: u16) -> Option<&ImageClickTarget> {
102        let (_, area_y, _, area_height) = self.last_chat_area?;
103
104        // Check if click is within chat area
105        if screen_row < area_y || screen_row >= area_y + area_height {
106            return None;
107        }
108
109        // Convert screen row to content line
110        let viewport_row = screen_row - area_y;
111        let content_line = viewport_row + self.last_scroll_position;
112
113        // Look up in click map
114        self.image_click_map
115            .iter()
116            .find(|(line, _)| *line == content_line)
117            .map(|(_, target)| target)
118    }
119}
120
121impl Default for ChatState {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127/// Props for ChatWidget
128pub struct ChatWidget<'a> {
129    pub messages: &'a [ChatMessage],
130    pub theme: &'a Theme,
131    /// Shared markdown parse cache: content hash → parsed lines.
132    pub markdown_cache: &'a mut FxHashMap<u64, Vec<Line<'static>>>,
133    pub show_reasoning: bool,
134}
135
136impl<'a> StatefulWidget for ChatWidget<'a> {
137    type State = ChatState;
138
139    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
140        let mut lines = Vec::new();
141
142        // Clear click map for this render pass
143        state.image_click_map.clear();
144        state.last_chat_area = Some((area.x, area.y, area.width, area.height));
145
146        let mut hidden_reasoning_notice_shown = false;
147
148        for (idx, msg) in self.messages.iter().enumerate() {
149            // Skip Tool messages - they're internal to the agent loop and their
150            // content is already displayed inline in the assistant's action blocks
151            if matches!(msg.role, MessageRole::Tool) {
152                continue;
153            }
154
155            if matches!(msg.kind, ChatMessageKind::ContextCheckpoint) {
156                if let Some(event_lines) = render_context_checkpoint_event(msg, self.theme) {
157                    lines.extend(event_lines);
158                    lines.push(Line::from(""));
159                }
160                continue;
161            }
162
163            let (role_prefix, role_color) = match msg.role {
164                MessageRole::User => (">", ratatui::style::Color::White),
165                MessageRole::Assistant => ("●", ratatui::style::Color::White),
166                MessageRole::System => ("●", self.theme.colors.system_message.to_color()),
167                MessageRole::Tool => unreachable!("Tool messages filtered above"),
168            };
169
170            if matches!(msg.role, MessageRole::Assistant) {
171                // Render thinking block if present
172                if let Some(ref thinking) = msg.thinking {
173                    // Skip rendering if thinking content is empty or literal "None"
174                    let thinking_trimmed = thinking.trim();
175                    if thinking_trimmed.is_empty()
176                        || thinking_trimmed == "None"
177                        || thinking_trimmed == "none"
178                    {
179                        // Don't render empty/null thinking blocks
180                    } else if self.show_reasoning {
181                        // Add "Thinking..." header in italic and dimmed with grayed white dot
182                        lines.push(Line::from(vec![
183                            Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
184                            Span::styled(
185                                "Thinking...",
186                                Style::new()
187                                    .fg(self.theme.colors.text_secondary.to_color())
188                                    .italic()
189                                    .dim(),
190                            ),
191                        ]));
192
193                        // Render thinking content with proper wrapping (2-space hanging indent)
194                        let wrapped = wrap_text_with_indent(
195                            thinking,
196                            area.width as usize,
197                            2, // first line indent (2 spaces)
198                            2, // continuation indent (2 spaces)
199                        );
200                        for wrapped_line in wrapped {
201                            lines.push(Line::from(Span::styled(
202                                wrapped_line,
203                                Style::new()
204                                    .fg(self.theme.colors.text_secondary.to_color())
205                                    .italic()
206                                    .dim(),
207                            )));
208                        }
209
210                        // Add blank line after thinking block
211                        lines.push(Line::from(""));
212                    } else if !hidden_reasoning_notice_shown {
213                        hidden_reasoning_notice_shown = true;
214                        let marker = if msg.content.trim().is_empty() && msg.actions.is_empty() {
215                            "Reasoning-only response hidden (/visible-reasoning on to show)"
216                        } else {
217                            "Reasoning hidden (/visible-reasoning on to show)"
218                        };
219                        lines.push(Line::from(vec![
220                            Span::styled("● ", Style::new().fg(ratatui::style::Color::DarkGray)),
221                            Span::styled(
222                                marker,
223                                Style::new()
224                                    .fg(self.theme.colors.text_secondary.to_color())
225                                    .italic()
226                                    .dim(),
227                            ),
228                        ]));
229                        if msg.content.trim().is_empty() && msg.actions.is_empty() {
230                            lines.push(Line::from(""));
231                            continue;
232                        }
233                    } else if msg.content.trim().is_empty() && msg.actions.is_empty() {
234                        continue;
235                    }
236                }
237
238                // With tool calling, message content is just text (no embedded action blocks)
239                // Use cached parsed markdown when available (avoids re-parsing every frame)
240                let mut hasher = rustc_hash::FxHasher::default();
241                msg.content.hash(&mut hasher);
242                let cache_key = hasher.finish();
243                let parsed_lines = if let Some(cached) = self.markdown_cache.get(&cache_key) {
244                    cached.clone()
245                } else {
246                    let parsed = parse_markdown(&msg.content);
247                    self.markdown_cache.insert(cache_key, parsed.clone());
248                    if self.markdown_cache.len() > crate::constants::MARKDOWN_CACHE_MAX_ENTRIES {
249                        self.markdown_cache.clear();
250                        self.markdown_cache.insert(cache_key, parsed.clone());
251                    }
252                    parsed
253                };
254
255                for (line_idx, mut parsed_line) in parsed_lines.into_iter().enumerate() {
256                    // Add role indicator to first line or 2-space margin to others
257                    if line_idx == 0 {
258                        // First line: prepend role indicator
259                        let mut spans = vec![Span::styled(
260                            format!("{} ", role_prefix),
261                            Style::new().fg(role_color).bold(),
262                        )];
263                        spans.extend(parsed_line.spans);
264                        parsed_line = Line::from(spans);
265                    } else {
266                        // Other lines: prepend 2-space margin
267                        let mut spans = vec![Span::raw("  ")];
268                        spans.extend(parsed_line.spans);
269                        parsed_line = Line::from(spans);
270                    }
271
272                    // Wrap the styled line if needed (continuation indent = 2)
273                    let wrapped = wrap_styled_line(parsed_line, area.width as usize, 2);
274                    lines.extend(wrapped);
275                }
276
277                // Render all actions at the end of the message
278                if !msg.actions.is_empty() {
279                    // Add blank line between text content and actions
280                    if !msg.content.trim().is_empty() {
281                        lines.push(Line::from(""));
282                    }
283                    render_actions(&msg.actions, &mut lines, self.theme, area.width as usize);
284                }
285            } else {
286                // For User messages: format timestamp and display on right edge
287                let formatted_timestamp = format_relative_timestamp(msg.timestamp);
288                let timestamp_len = formatted_timestamp.len();
289                let min_gap = 3; // minimum spaces between text and timestamp
290
291                // Content is clean — timestamps are injected at API call time only
292                let cleaned_content = &msg.content;
293
294                // Reserve space on the first line for role prefix + gap + timestamp
295                // so text wraps early enough to not overlap the timestamp
296                let role_prefix_width = role_prefix.len() + 1; // "You " = prefix + space
297                let first_line_reserved = role_prefix_width + min_gap + timestamp_len;
298
299                // Manually wrap the user message with hanging indent (2 spaces)
300                let wrapped = wrap_text_with_indent(
301                    cleaned_content,
302                    area.width as usize,
303                    first_line_reserved, // reserve space for prefix + gap + timestamp on first line
304                    2,                   // continuation indent
305                );
306
307                for (line_idx, wrapped_line) in wrapped.iter().enumerate() {
308                    if line_idx == 0 {
309                        // First line: add role prefix and timestamp on right
310                        let text_content = wrapped_line.trim_start(); // Remove the indent we added
311                        let text_len = text_content.len();
312
313                        let mut spans = vec![
314                            Span::styled(
315                                format!("{} ", role_prefix),
316                                Style::new().fg(role_color).bold(),
317                            ),
318                            Span::raw(text_content.to_string()),
319                        ];
320
321                        // Always add at least min_gap spaces, plus any extra from word-boundary slack
322                        let content_width = role_prefix_width + text_len;
323                        let total_used = content_width + min_gap + timestamp_len;
324                        let extra_padding = (area.width as usize).saturating_sub(total_used);
325                        spans.push(Span::raw(" ".repeat(min_gap + extra_padding)));
326                        spans.push(Span::styled(
327                            formatted_timestamp.clone(),
328                            Style::new().fg(ratatui::style::Color::Rgb(136, 136, 136)),
329                        ));
330
331                        lines.push(Line::from(spans));
332                    } else {
333                        // Continuation lines: already have 2-space margin from wrap_text_with_indent
334                        lines.push(Line::from(wrapped_line.clone()));
335                    }
336                }
337            }
338
339            // Show image indicators under user and assistant messages.
340            // User images come from clipboard paste (`Attachment`); assistant
341            // images come from tool executions that emitted `ProgressEvent::
342            // Artifact` during their run — screenshot captures, inline
343            // previews from computer-use, etc. Both land in `msg.images` as
344            // base64 strings and render the same way.
345            if matches!(msg.role, MessageRole::User | MessageRole::Assistant)
346                && let Some(ref images) = msg.images
347                && !images.is_empty()
348            {
349                for (i, _) in images.iter().enumerate() {
350                    // Record this line in the click map before pushing
351                    let content_line = lines.len() as u16;
352                    state.image_click_map.push((
353                        content_line,
354                        ImageClickTarget {
355                            message_index: idx,
356                            image_index: i,
357                        },
358                    ));
359                    lines.push(Line::from(vec![
360                        Span::styled("  ⎿ ", Style::new().fg(self.theme.colors.info.to_color())),
361                        Span::styled(
362                            format!("[Image #{}]", i + 1),
363                            Style::new().fg(self.theme.colors.info.to_color()).italic(),
364                        ),
365                    ]));
366                }
367            }
368
369            lines.push(Line::from(""));
370        }
371
372        // NOTE: The response buffer is NOT rendered during streaming (buffering mode).
373        // The response is buffered invisibly and only shown when generation is complete.
374        // This provides a Claude Code-like experience where the complete response
375        // appears instantly instead of streaming character-by-character.
376        //
377        // The status line shows progress: "↑ Sending..." → "↓ Streaming..." with timer
378
379        // NOTE: Wrapping is disabled because we handle it manually with hanging indents
380        // Calculate content height and viewport for proper scroll clamping
381        let content_height = lines.len() as u16;
382        let viewport_height = area.height;
383
384        let scroll_pos = state.get_scroll_position(content_height, viewport_height);
385        state.last_scroll_position = scroll_pos;
386
387        let paragraph = Paragraph::new(lines)
388            .block(Block::default())
389            .scroll((scroll_pos, 0));
390
391        paragraph.render(area, buf);
392    }
393}
394
395fn render_context_checkpoint_event(msg: &ChatMessage, theme: &Theme) -> Option<Vec<Line<'static>>> {
396    if !matches!(msg.role, MessageRole::User) {
397        return None;
398    }
399
400    let metadata = msg.metadata.as_ref();
401    let trigger = metadata
402        .and_then(|value| value.get("trigger"))
403        .and_then(|value| value.as_str())
404        .unwrap_or("manual");
405    let before_tokens = metadata.and_then(|value| metadata_usize(value, "before_tokens"));
406    let after_tokens = metadata.and_then(|value| metadata_usize(value, "after_tokens"));
407    let archived_messages =
408        metadata.and_then(|value| metadata_usize(value, "archived_message_count"));
409    let preserved_messages =
410        metadata.and_then(|value| metadata_usize(value, "preserved_message_count"));
411    let duration_secs = metadata
412        .and_then(|value| value.get("duration_secs"))
413        .and_then(|value| value.as_f64());
414    let verified = metadata
415        .and_then(|value| value.get("verified"))
416        .and_then(|value| value.as_bool());
417    let verification_error = metadata
418        .and_then(|value| value.get("verification_error"))
419        .and_then(|value| value.as_str());
420
421    let action_color = theme.colors.info.to_color();
422    let mut result = match (before_tokens, after_tokens) {
423        (Some(before), Some(after)) => {
424            format!(
425                "Success, {} -> {} tokens",
426                format_compact_count(before),
427                format_compact_count(after)
428            )
429        },
430        _ => "Success".to_string(),
431    };
432
433    if let Some(count) = archived_messages {
434        result.push_str(&format!(
435            ", archived {} {}",
436            count,
437            if count == 1 { "message" } else { "messages" }
438        ));
439    }
440    if let Some(count) = preserved_messages {
441        result.push_str(&format!(
442            ", preserved {} {}",
443            count,
444            if count == 1 { "message" } else { "messages" }
445        ));
446    }
447    if let Some(verified) = verified {
448        if verified {
449            result.push_str(", verified");
450        } else {
451            result.push_str(", draft fallback");
452        }
453    }
454    result = append_action_duration(result, duration_secs);
455
456    let mut lines = vec![
457        Line::from(vec![
458            Span::styled("● ", Style::new().fg(action_color).bold()),
459            Span::styled("Compact(", Style::new().fg(action_color).bold()),
460            Span::styled(
461                trigger.to_string(),
462                Style::new().fg(theme.colors.text_secondary.to_color()),
463            ),
464            Span::styled(")", Style::new().fg(action_color).bold()),
465        ]),
466        Line::from(vec![
467            Span::styled("  ⎿ ", Style::new().fg(action_color)),
468            Span::styled(
469                result,
470                Style::new().fg(theme.colors.text_secondary.to_color()),
471            ),
472        ]),
473    ];
474
475    if let Some(error) = verification_error.filter(|error| !error.trim().is_empty()) {
476        lines.push(Line::from(vec![
477            Span::styled("    ", Style::new().fg(action_color)),
478            Span::styled(
479                format!("verification: {}", compact_inline_error(error, 180)),
480                Style::new().fg(theme.colors.warning.to_color()),
481            ),
482        ]));
483    }
484
485    Some(lines)
486}
487
488fn metadata_usize(value: &serde_json::Value, key: &str) -> Option<usize> {
489    value
490        .get(key)?
491        .as_u64()
492        .and_then(|value| usize::try_from(value).ok())
493}
494
495fn compact_inline_error(text: &str, max_chars: usize) -> String {
496    let text = text.trim();
497    if text.chars().count() <= max_chars {
498        return text.to_string();
499    }
500    let keep = max_chars.saturating_sub(3);
501    let mut out: String = text.chars().take(keep).collect();
502    out.push_str("...");
503    out
504}
505
506/// Render actions in Claude Code style
507fn render_actions(
508    actions: &[ActionDisplay],
509    lines: &mut Vec<Line>,
510    theme: &Theme,
511    viewport_width: usize,
512) {
513    for (action_idx, action) in actions.iter().enumerate() {
514        if action_idx > 0 {
515            lines.push(Line::from(""));
516        }
517        let action_color = match action.action_type.as_str() {
518            "Write" | "Edit" => theme.colors.success.to_color(),
519            "Delete" => theme.colors.warning.to_color(),
520            _ => theme.colors.info.to_color(),
521        };
522
523        // Header: ● Type(target)
524        lines.push(Line::from(vec![
525            Span::styled("● ", Style::new().fg(action_color).bold()),
526            Span::styled(
527                format!("{}(", action.action_type),
528                Style::new().fg(action_color).bold(),
529            ),
530            Span::styled(
531                action.target.clone(),
532                Style::new().fg(theme.colors.text_secondary.to_color()),
533            ),
534            Span::styled(")", Style::new().fg(action_color).bold()),
535        ]));
536
537        match &action.result {
538            ActionResult::Success { .. } => {
539                // Result summary from details enum
540                let result_msg = match &action.details {
541                    ActionDetails::FileContent { line_count, .. } => {
542                        let base = format!(
543                            "Success, {} {} written",
544                            line_count,
545                            if *line_count == 1 { "line" } else { "lines" }
546                        );
547                        append_action_duration(base, action.duration_seconds)
548                    },
549                    ActionDetails::Diff { summary, .. } => summary.clone(),
550                    ActionDetails::Preview { text, .. } => text.clone(),
551                    ActionDetails::Simple => match action.action_type.as_str() {
552                        "Delete" => append_action_duration(
553                            format!("Success, deleted {}", action.target),
554                            action.duration_seconds,
555                        ),
556                        _ => append_action_duration("Success".to_string(), action.duration_seconds),
557                    },
558                };
559
560                for (idx, line) in result_msg.lines().enumerate() {
561                    let prefix = if idx == 0 { "  ⎿ " } else { "    " };
562                    lines.push(Line::from(vec![
563                        Span::styled(prefix, Style::new().fg(action_color)),
564                        Span::styled(
565                            line.to_string(),
566                            Style::new().fg(theme.colors.text_secondary.to_color()),
567                        ),
568                    ]));
569                }
570
571                // Write: syntax-highlighted file preview
572                if let ActionDetails::FileContent {
573                    content,
574                    line_count,
575                } = &action.details
576                {
577                    let preview_lines: Vec<&str> = content.lines().take(10).collect();
578                    if !preview_lines.is_empty() {
579                        lines.push(Line::from(vec![Span::styled(
580                            "    ",
581                            Style::new().fg(action_color),
582                        )]));
583
584                        let preview_content = preview_lines.join("\n");
585                        let mut parsed = parse_markdown(&format!("```\n{}\n```", preview_content));
586                        for parsed_line in parsed.iter_mut() {
587                            let mut new_spans =
588                                vec![Span::styled("    ", Style::new().fg(action_color))];
589                            new_spans.append(&mut parsed_line.spans);
590                            parsed_line.spans = new_spans;
591                        }
592                        lines.extend(parsed);
593
594                        if *line_count > 10 {
595                            lines.push(Line::from(vec![
596                                Span::styled("    ", Style::new().fg(action_color)),
597                                Span::styled(
598                                    format!("... ({} more lines)", line_count - 10),
599                                    Style::new()
600                                        .fg(theme.colors.text_disabled.to_color())
601                                        .italic(),
602                                ),
603                            ]));
604                        }
605                    }
606                }
607
608                // Edit: color-coded diff
609                if let ActionDetails::Diff { diff, .. } = &action.details {
610                    let diff_lines: Vec<&str> = diff.lines().collect();
611                    let display_lines: Vec<&str> = diff_lines.iter().take(80).copied().collect();
612
613                    if !display_lines.is_empty() {
614                        let removed_bg = ratatui::style::Color::Rgb(60, 20, 20);
615                        let added_bg = ratatui::style::Color::Rgb(20, 50, 20);
616
617                        for diff_line in &display_lines {
618                            // Delegate the producer-format awareness to
619                            // `parse_diff_line`, which lives next to the
620                            // marker constants and stays in lockstep with
621                            // any future format change.
622                            match parse_diff_line(diff_line) {
623                                DiffLineKind::Removed => {
624                                    let text = format!("    {}", diff_line);
625                                    let padded =
626                                        format!("{:<width$}", text, width = viewport_width);
627                                    lines.push(Line::from(vec![Span::styled(
628                                        padded,
629                                        Style::new()
630                                            .fg(theme.colors.error.to_color())
631                                            .bg(removed_bg),
632                                    )]));
633                                },
634                                DiffLineKind::Added => {
635                                    let text = format!("    {}", diff_line);
636                                    let padded =
637                                        format!("{:<width$}", text, width = viewport_width);
638                                    lines.push(Line::from(vec![Span::styled(
639                                        padded,
640                                        Style::new()
641                                            .fg(theme.colors.success.to_color())
642                                            .bg(added_bg),
643                                    )]));
644                                },
645                                DiffLineKind::Context => {
646                                    lines.push(Line::from(vec![
647                                        Span::styled("    ", Style::new().fg(action_color)),
648                                        Span::styled(
649                                            diff_line.to_string(),
650                                            Style::new().fg(theme.colors.text_secondary.to_color()),
651                                        ),
652                                    ]));
653                                },
654                            }
655                        }
656
657                        let remaining = diff_lines.len().saturating_sub(display_lines.len());
658                        if remaining > 0 {
659                            lines.push(Line::from(vec![
660                                Span::styled("    ", Style::new().fg(action_color)),
661                                Span::styled(
662                                    format!("... ({} more lines)", remaining),
663                                    Style::new()
664                                        .fg(theme.colors.text_disabled.to_color())
665                                        .italic(),
666                                ),
667                            ]));
668                        }
669                    }
670                }
671            },
672            ActionResult::Error { error } => {
673                let error =
674                    append_action_duration(format!("Error: {}", error), action.duration_seconds);
675                lines.push(Line::from(vec![
676                    Span::styled("  ⎿ ", Style::new().fg(theme.colors.error.to_color())),
677                    Span::styled(error, Style::new().fg(theme.colors.error.to_color())),
678                ]));
679            },
680        }
681    }
682}
683
684fn append_action_duration(mut text: String, duration_seconds: Option<f64>) -> String {
685    if let Some(seconds) = duration_seconds {
686        text.push_str(", took ");
687        text.push_str(&format_action_duration(seconds));
688    }
689    text
690}
691
692fn format_action_duration(seconds: f64) -> String {
693    if seconds < 1.0 {
694        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
695    } else if seconds < 10.0 {
696        format!("{:.1}s", seconds)
697    } else {
698        format!("{}s", seconds.round() as u64)
699    }
700}
701
702/// Wrap text with hanging indent support.
703///
704/// `width`, `first_line_indent`, and `continuation_indent` are all measured
705/// in **display cells**, not bytes. Word lengths are also measured in cells
706/// via `UnicodeWidthStr::width` so CJK / emoji wrap at the visual edge —
707/// previously a CJK paragraph would wrap after ~1/3 of the line because
708/// `word.len()` (bytes) is roughly 3× `word.width()` (cells) for 3-byte
709/// codepoints.
710fn wrap_text_with_indent(
711    text: &str,
712    width: usize,
713    first_line_indent: usize,
714    continuation_indent: usize,
715) -> Vec<String> {
716    let mut wrapped_lines = Vec::new();
717
718    for (line_idx, line) in text.lines().enumerate() {
719        if line.is_empty() {
720            wrapped_lines.push(String::new());
721            continue;
722        }
723
724        let current_indent = if line_idx == 0 {
725            first_line_indent
726        } else {
727            continuation_indent
728        };
729        let available_width = width.saturating_sub(current_indent);
730
731        if available_width == 0 {
732            wrapped_lines.push(" ".repeat(current_indent));
733            continue;
734        }
735
736        let words: Vec<&str> = line.split_whitespace().collect();
737        if words.is_empty() {
738            wrapped_lines.push(" ".repeat(current_indent));
739            continue;
740        }
741
742        let mut current_line = String::with_capacity(width);
743        current_line.push_str(&" ".repeat(current_indent));
744        // Display-cell widths: indent is ASCII spaces (1 cell each), so
745        // start fresh and let words contribute their own cell widths.
746        let mut current_length = 0;
747
748        for (word_idx, word) in words.iter().enumerate() {
749            let word_width = word.width();
750
751            if word_idx == 0 {
752                // First word always fits on the line
753                current_line.push_str(word);
754                current_length = word_width;
755            } else if current_length + 1 + word_width <= available_width {
756                // Word fits on current line (the +1 accounts for the
757                // separator space, which is 1 cell)
758                current_line.push(' ');
759                current_line.push_str(word);
760                current_length += 1 + word_width;
761            } else {
762                // Word doesn't fit, start a new line
763                wrapped_lines.push(current_line);
764                current_line = String::with_capacity(width);
765                current_line.push_str(&" ".repeat(continuation_indent));
766                current_line.push_str(word);
767                current_length = word_width;
768            }
769        }
770
771        // Add the last line
772        if !current_line.trim().is_empty() {
773            wrapped_lines.push(current_line);
774        }
775    }
776
777    wrapped_lines
778}
779
780/// Wrap a styled Line with hanging indent, preserving all span styles
781/// Returns multiple Line objects with proper indentation
782fn wrap_styled_line(
783    line: Line<'static>,
784    width: usize,
785    continuation_indent: usize,
786) -> Vec<Line<'static>> {
787    // Widths are counted in display cells (via `UnicodeWidthStr`), not
788    // bytes. This makes CJK double-width chars and emoji wrap at the
789    // correct visual column, and avoids over-wrapping multi-byte ASCII-
790    // looking glyphs.
791    let total_width: usize = line.spans.iter().map(|s| s.content.width()).sum();
792
793    // If the line fits within width, return as-is
794    if total_width <= width {
795        return vec![line];
796    }
797
798    // Line needs wrapping - extract all text and styles
799    let mut result_lines = Vec::new();
800    let mut current_line_spans = Vec::new();
801    let mut current_line_width = 0usize;
802    let available_width = width.saturating_sub(continuation_indent);
803
804    for span in line.spans.clone() {
805        let span_text = span.content.to_string();
806        let span_style = span.style;
807
808        // Split span text by words
809        let words: Vec<&str> = span_text.split_whitespace().collect();
810
811        for (word_idx, word) in words.iter().enumerate() {
812            let word_with_space = if word_idx > 0 || current_line_width > 0 {
813                format!(" {}", word)
814            } else {
815                word.to_string()
816            };
817
818            let word_width = word_with_space.width();
819
820            if current_line_width == 0 && result_lines.is_empty() {
821                // First word of first line - no indent
822                current_line_spans.push(Span::styled(word_with_space, span_style));
823                current_line_width += word_width;
824            } else if current_line_width + word_width <= available_width {
825                // Word fits on current line
826                current_line_spans.push(Span::styled(word_with_space, span_style));
827                current_line_width += word_width;
828            } else {
829                // Word doesn't fit - finish current line and start new one
830                result_lines.push(Line::from(current_line_spans));
831                current_line_spans = vec![Span::raw(" ".repeat(continuation_indent))];
832                current_line_spans.push(Span::styled(word.to_string(), span_style));
833                current_line_width = word.width();
834            }
835        }
836    }
837
838    // Add the last line if it has content
839    if !current_line_spans.is_empty() {
840        result_lines.push(Line::from(current_line_spans));
841    }
842
843    if result_lines.is_empty() {
844        vec![line]
845    } else {
846        result_lines
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    #[test]
855    fn context_checkpoint_renders_as_compact_event() {
856        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
857        msg.kind = ChatMessageKind::ContextCheckpoint;
858        msg.metadata = Some(serde_json::json!({
859            "trigger": "manual",
860            "before_tokens": 43_800,
861            "after_tokens": 9_200,
862            "archived_message_count": 18,
863            "preserved_message_count": 4,
864            "duration_secs": 2.4,
865            "verified": true,
866        }));
867
868        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
869        let rendered = lines
870            .iter()
871            .map(|line| {
872                line.spans
873                    .iter()
874                    .map(|span| span.content.as_ref())
875                    .collect::<String>()
876            })
877            .collect::<Vec<_>>()
878            .join("\n");
879
880        assert!(rendered.contains("Compact(manual)"));
881        assert!(rendered.contains("43.8k -> 9.2k tokens"));
882        assert!(rendered.contains("archived 18 messages"));
883        assert!(rendered.contains("preserved 4 messages"));
884        assert!(rendered.contains("verified"));
885        assert!(!rendered.contains("full checkpoint summary"));
886    }
887
888    #[test]
889    fn context_checkpoint_renders_verification_fallback() {
890        let mut msg = ChatMessage::user("full checkpoint summary hidden from the chat log");
891        msg.kind = ChatMessageKind::ContextCheckpoint;
892        msg.metadata = Some(serde_json::json!({
893            "trigger": "auto_threshold",
894            "before_tokens": 43_800,
895            "after_tokens": 9_200,
896            "archived_message_count": 18,
897            "preserved_message_count": 4,
898            "duration_secs": 2.4,
899            "verified": false,
900            "verification_error": "provider overloaded",
901        }));
902
903        let lines = render_context_checkpoint_event(&msg, &Theme::dark()).expect("event lines");
904        let rendered = lines
905            .iter()
906            .map(|line| {
907                line.spans
908                    .iter()
909                    .map(|span| span.content.as_ref())
910                    .collect::<String>()
911            })
912            .collect::<Vec<_>>()
913            .join("\n");
914
915        assert!(rendered.contains("Compact(auto_threshold)"));
916        assert!(rendered.contains("draft fallback"));
917        assert!(rendered.contains("verification: provider overloaded"));
918    }
919
920    /// CJK characters are 3 bytes but 2 display cells each. The
921    /// byte-length version of `wrap_styled_line` would incorrectly
922    /// over-wrap such input. This test asserts the display-width
923    /// version keeps CJK-only input on a single line when the display
924    /// width fits, even when the byte length exceeds the width.
925    #[test]
926    fn wrap_styled_line_uses_display_width_for_cjk() {
927        // "你好世界" is 4 CJK chars × 3 bytes = 12 bytes, × 2 display cells = 8 cells.
928        // Target width of 10: byte-length would see 12 > 10 and wrap;
929        // display-width sees 8 <= 10 and keeps it on one line.
930        let line = Line::from(Span::raw("你好世界".to_string()));
931        let wrapped = wrap_styled_line(line, 10, 2);
932        assert_eq!(
933            wrapped.len(),
934            1,
935            "CJK input fitting in display-width should NOT be wrapped; got {} lines",
936            wrapped.len()
937        );
938    }
939
940    /// Sanity: ASCII wrapping still works and produces >= 2 lines when
941    /// the input exceeds the width.
942    #[test]
943    fn wrap_styled_line_ascii_wraps_when_too_long() {
944        let line = Line::from(Span::raw(
945            "the quick brown fox jumps over the lazy dog".to_string(),
946        ));
947        let wrapped = wrap_styled_line(line, 15, 2);
948        assert!(
949            wrapped.len() >= 2,
950            "long ASCII input should wrap to multiple lines; got {}",
951            wrapped.len()
952        );
953    }
954
955    /// Counterpart to `wrap_styled_line_uses_display_width_for_cjk` for
956    /// the plain-string wrapper used by user messages and thinking blocks.
957    /// The byte-based version would wrap a 4-CJK paragraph after the second
958    /// char (12 bytes > 10) even though it fits in 8 cells. Display-width
959    /// version keeps it on one line.
960    #[test]
961    fn wrap_text_with_indent_uses_display_width_for_cjk() {
962        // "你好世界" = 4 chars, 12 bytes, 8 display cells. Width 12 cells
963        // with 0 indent: should fit on one line.
964        let wrapped = wrap_text_with_indent("你好世界", 12, 0, 0);
965        assert_eq!(
966            wrapped.len(),
967            1,
968            "CJK paragraph fitting in display width should not wrap; got {} lines: {:?}",
969            wrapped.len(),
970            wrapped
971        );
972        assert_eq!(wrapped[0].trim_start(), "你好世界");
973    }
974
975    /// Mixed content: CJK + ASCII should still wrap correctly when the
976    /// total exceeds available cells.
977    #[test]
978    fn wrap_text_with_indent_wraps_cjk_at_visual_edge() {
979        // "你好 world 世界" = 2 + 1 + 5 + 1 + 2 = 11 cells without spaces,
980        // with separators: 2 + 1 + 5 + 1 + 4 = 13 cells. Width 8 cells should
981        // produce ≥ 2 lines.
982        let wrapped = wrap_text_with_indent("你好 world 世界", 8, 0, 0);
983        assert!(
984            wrapped.len() >= 2,
985            "mixed CJK+ASCII exceeding width should wrap; got {} lines: {:?}",
986            wrapped.len(),
987            wrapped
988        );
989    }
990}