Skip to main content

j_cli/command/chat/
render.rs

1use super::app::{ChatApp, ChatMode, MsgLinesCache, PerMsgCache};
2use super::markdown::markdown_to_lines;
3use super::theme::Theme;
4use ratatui::{
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7};
8use std::io::Write;
9
10pub fn find_stable_boundary(content: &str) -> usize {
11    // 统计 ``` 出现次数,奇数说明有未闭合的代码块
12    let mut fence_count = 0usize;
13    let mut last_safe_boundary = 0usize;
14    let mut i = 0;
15    let bytes = content.as_bytes();
16    while i < bytes.len() {
17        // 检测 ``` 围栏
18        if i + 2 < bytes.len() && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
19            fence_count += 1;
20            i += 3;
21            // 跳过同行剩余内容(语言标识等)
22            while i < bytes.len() && bytes[i] != b'\n' {
23                i += 1;
24            }
25            continue;
26        }
27        // 检测 \n\n 段落边界
28        if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
29            // 只有在代码块外才算安全边界
30            if fence_count % 2 == 0 {
31                last_safe_boundary = i + 2; // 指向下一段的起始位置
32            }
33            i += 2;
34            continue;
35        }
36        i += 1;
37    }
38    last_safe_boundary
39}
40
41/// 增量构建所有消息的渲染行(P0 + P1 优化版本)
42/// - P0:按消息粒度缓存,历史消息内容未变时直接复用渲染行
43/// - P1:流式消息增量段落渲染,只重新解析最后一个不完整段落
44/// 返回 (渲染行列表, 消息起始行号映射, 按消息缓存, 流式稳定行缓存, 流式稳定偏移)
45pub fn build_message_lines_incremental(
46    app: &ChatApp,
47    inner_width: usize,
48    bubble_max_width: usize,
49    old_cache: Option<&MsgLinesCache>,
50) -> (
51    Vec<Line<'static>>,
52    Vec<(usize, usize)>,
53    Vec<PerMsgCache>,
54    Vec<Line<'static>>,
55    usize,
56) {
57    struct RenderMsg {
58        role: String,
59        content: String,
60        msg_index: Option<usize>,
61        tool_calls: Option<Vec<super::model::ToolCallItem>>,
62        role_label: Option<String>,
63    }
64    let mut render_msgs: Vec<RenderMsg> = app
65        .session
66        .messages
67        .iter()
68        .enumerate()
69        .map(|(i, m)| RenderMsg {
70            role: m.role.clone(),
71            content: m.content.clone(),
72            msg_index: Some(i),
73            tool_calls: m.tool_calls.clone(),
74            role_label: m
75                .tool_call_id
76                .as_ref()
77                .map(|id| format!("工具 {}", &id[..id.len().min(8)])),
78        })
79        .collect();
80
81    // 如果正在流式接收,添加一条临时的 assistant 消息
82    let streaming_content_str = if app.is_loading {
83        let streaming = app.streaming_content.lock().unwrap().clone();
84        if !streaming.is_empty() {
85            render_msgs.push(RenderMsg {
86                role: "assistant".to_string(),
87                content: streaming.clone(),
88                msg_index: None,
89                tool_calls: None,
90                role_label: None,
91            });
92            Some(streaming)
93        } else {
94            render_msgs.push(RenderMsg {
95                role: "assistant".to_string(),
96                content: "◍".to_string(),
97                msg_index: None,
98                tool_calls: None,
99                role_label: None,
100            });
101            None
102        }
103    } else {
104        None
105    };
106
107    let t = &app.theme;
108    let is_browse_mode = app.mode == ChatMode::Browse;
109    let mut lines: Vec<Line> = Vec::new();
110    let mut msg_start_lines: Vec<(usize, usize)> = Vec::new();
111    let mut per_msg_cache: Vec<PerMsgCache> = Vec::new();
112
113    // 判断旧缓存中的 per_msg_lines 是否可以复用(bubble_max_width 相同且浏览模式状态一致)
114    let can_reuse_per_msg = old_cache
115        .map(|c| c.bubble_max_width == bubble_max_width)
116        .unwrap_or(false);
117
118    for msg in &render_msgs {
119        let is_selected = is_browse_mode
120            && msg.msg_index.is_some()
121            && msg.msg_index.unwrap() == app.browse_msg_index;
122
123        // 记录消息起始行号
124        if let Some(idx) = msg.msg_index {
125            msg_start_lines.push((idx, lines.len()));
126        }
127
128        // P0 优化:对于有 msg_index 的历史消息,尝试复用旧缓存
129        if let Some(idx) = msg.msg_index {
130            if can_reuse_per_msg {
131                if let Some(old_c) = old_cache {
132                    // 查找旧缓存中同索引的消息
133                    if let Some(old_per) = old_c.per_msg_lines.iter().find(|p| p.msg_index == idx) {
134                        // 内容长度相同 → 消息内容未变,且浏览选中状态一致
135                        // 使用缓存中记录的 is_selected 字段来判断
136                        if old_per.content_len == msg.content.len()
137                            && old_per.is_selected == is_selected
138                        {
139                            // 直接复用旧缓存的渲染行
140                            lines.extend(old_per.lines.iter().cloned());
141                            per_msg_cache.push(PerMsgCache {
142                                content_len: old_per.content_len,
143                                lines: old_per.lines.clone(),
144                                msg_index: idx,
145                                is_selected,
146                            });
147                            continue;
148                        }
149                    }
150                }
151            }
152        }
153
154        // 缓存未命中 / 流式消息 → 重新渲染
155        let msg_lines_start = lines.len();
156        match msg.role.as_str() {
157            "user" => {
158                render_user_msg(
159                    &msg.content,
160                    is_selected,
161                    inner_width,
162                    bubble_max_width,
163                    &mut lines,
164                    t,
165                );
166            }
167            "assistant" => {
168                if msg.msg_index.is_none() {
169                    // 流式消息:P1 增量段落渲染(在后面单独处理)
170                    // 这里先跳过,后面统一处理
171                    // 先标记位置
172                } else if msg.tool_calls.is_some() {
173                    // assistant 发起工具调用的消息
174                    render_tool_call_request_msg(
175                        &msg.tool_calls.as_ref().unwrap(),
176                        bubble_max_width,
177                        &mut lines,
178                        t,
179                    );
180                } else {
181                    // 已完成的 assistant 消息:完整 Markdown 渲染
182                    render_assistant_msg(
183                        &msg.content,
184                        is_selected,
185                        bubble_max_width,
186                        &mut lines,
187                        t,
188                    );
189                }
190            }
191            "tool" => {
192                render_tool_result_msg(
193                    &msg.content,
194                    msg.role_label.as_deref().unwrap_or("工具结果"),
195                    &mut lines,
196                    t,
197                );
198            }
199            "system" => {
200                lines.push(Line::from(""));
201                let wrapped = wrap_text(&msg.content, inner_width.saturating_sub(8));
202                for wl in wrapped {
203                    lines.push(Line::from(Span::styled(
204                        format!("    {}  {}", "sys", wl),
205                        Style::default().fg(t.text_system),
206                    )));
207                }
208            }
209            _ => {}
210        }
211
212        // 流式消息的渲染在 assistant 分支中被跳过了,这里处理
213        if msg.role == "assistant" && msg.msg_index.is_none() {
214            // P1 增量段落渲染
215            let bubble_bg = t.bubble_ai;
216            let pad_left_w = 3usize;
217            let pad_right_w = 3usize;
218            let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
219            let bubble_total_w = bubble_max_width;
220
221            // AI 标签
222            lines.push(Line::from(""));
223            lines.push(Line::from(Span::styled(
224                "  AI",
225                Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD),
226            )));
227
228            // 上边距
229            lines.push(Line::from(vec![Span::styled(
230                " ".repeat(bubble_total_w),
231                Style::default().bg(bubble_bg),
232            )]));
233
234            // 增量段落渲染:取旧缓存中的 stable_lines 和 stable_offset
235            let (mut stable_lines, mut stable_offset) = if let Some(old_c) = old_cache {
236                if old_c.bubble_max_width == bubble_max_width {
237                    (
238                        old_c.streaming_stable_lines.clone(),
239                        old_c.streaming_stable_offset,
240                    )
241                } else {
242                    (Vec::<Line<'static>>::new(), 0)
243                }
244            } else {
245                (Vec::<Line<'static>>::new(), 0)
246            };
247
248            let content = &msg.content;
249            // 找到当前内容中最后一个安全的段落边界
250            let boundary = find_stable_boundary(content);
251
252            // 如果有新的完整段落超过了上次缓存的偏移
253            if boundary > stable_offset {
254                // 增量解析:从上次偏移到新边界的新完成段落
255                let new_stable_text = &content[stable_offset..boundary];
256                let new_md_lines = markdown_to_lines(new_stable_text, md_content_w + 2, t);
257                // 将新段落的渲染行包装成气泡样式并追加到 stable_lines
258                for md_line in new_md_lines {
259                    let bubble_line = wrap_md_line_in_bubble(
260                        md_line,
261                        bubble_bg,
262                        pad_left_w,
263                        pad_right_w,
264                        bubble_total_w,
265                    );
266                    stable_lines.push(bubble_line);
267                }
268                stable_offset = boundary;
269            }
270
271            // 追加已缓存的稳定段落行
272            lines.extend(stable_lines.iter().cloned());
273
274            // 只对最后一个不完整段落做全量 Markdown 解析
275            let tail = &content[boundary..];
276            if !tail.is_empty() {
277                let tail_md_lines = markdown_to_lines(tail, md_content_w + 2, t);
278                for md_line in tail_md_lines {
279                    let bubble_line = wrap_md_line_in_bubble(
280                        md_line,
281                        bubble_bg,
282                        pad_left_w,
283                        pad_right_w,
284                        bubble_total_w,
285                    );
286                    lines.push(bubble_line);
287                }
288            }
289
290            // 下边距
291            lines.push(Line::from(vec![Span::styled(
292                " ".repeat(bubble_total_w),
293                Style::default().bg(bubble_bg),
294            )]));
295
296            // 记录最终的 stable 状态用于返回
297            // (在函数末尾统一返回)
298            // 先用局部变量暂存
299            let _ = (stable_lines.clone(), stable_offset);
300
301            // 构建末尾留白和返回值时统一处理
302        } else if let Some(idx) = msg.msg_index {
303            // 缓存此历史消息的渲染行
304            let msg_lines_end = lines.len();
305            let this_msg_lines: Vec<Line<'static>> = lines[msg_lines_start..msg_lines_end].to_vec();
306            let is_selected = is_browse_mode
307                && msg.msg_index.is_some()
308                && msg.msg_index.unwrap() == app.browse_msg_index;
309            per_msg_cache.push(PerMsgCache {
310                content_len: msg.content.len(),
311                lines: this_msg_lines,
312                msg_index: idx,
313                is_selected,
314            });
315        }
316    }
317
318    // ========== 内联工具确认区 ==========
319    if app.mode == ChatMode::ToolConfirm {
320        if let Some(tc) = app.active_tool_calls.get(app.pending_tool_idx) {
321            let t = &app.theme;
322            let confirm_bg = t.tool_confirm_bg;
323            let border_color = t.tool_confirm_border;
324            let content_w = bubble_max_width.saturating_sub(6); // 左右各 3 的 padding
325
326            // 空行
327            lines.push(Line::from(""));
328
329            // 标题行
330            lines.push(Line::from(Span::styled(
331                "  🔧 工具调用确认",
332                Style::default()
333                    .fg(t.tool_confirm_title)
334                    .add_modifier(Modifier::BOLD),
335            )));
336
337            // 顶边框
338            let top_border = format!("  ┌{}┐", "─".repeat(bubble_max_width.saturating_sub(4)));
339            lines.push(Line::from(Span::styled(
340                top_border,
341                Style::default().fg(border_color).bg(confirm_bg),
342            )));
343
344            // 工具名行
345            {
346                let label = "工具: ";
347                let name = &tc.tool_name;
348                let text_content = format!("{}{}", label, name);
349                let fill = content_w.saturating_sub(display_width(&text_content));
350                lines.push(Line::from(vec![
351                    Span::styled("  │ ", Style::default().fg(border_color).bg(confirm_bg)),
352                    Span::styled(" ".repeat(1), Style::default().bg(confirm_bg)),
353                    Span::styled(
354                        label,
355                        Style::default().fg(t.tool_confirm_label).bg(confirm_bg),
356                    ),
357                    Span::styled(
358                        name.clone(),
359                        Style::default()
360                            .fg(t.tool_confirm_name)
361                            .bg(confirm_bg)
362                            .add_modifier(Modifier::BOLD),
363                    ),
364                    Span::styled(
365                        " ".repeat(fill.saturating_sub(1)),
366                        Style::default().bg(confirm_bg),
367                    ),
368                    Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
369                ]));
370            }
371
372            // 确认信息行(可能需要截断)
373            {
374                let max_msg_w = content_w.saturating_sub(2);
375                let confirm_msg = if display_width(&tc.confirm_message) > max_msg_w {
376                    let mut end = max_msg_w.saturating_sub(3);
377                    while end > 0 && !tc.confirm_message.is_char_boundary(end) {
378                        end -= 1;
379                    }
380                    format!("{}...", &tc.confirm_message[..end])
381                } else {
382                    tc.confirm_message.clone()
383                };
384                let msg_w = display_width(&confirm_msg);
385                let fill = content_w.saturating_sub(msg_w + 2);
386                lines.push(Line::from(vec![
387                    Span::styled("  │ ", Style::default().fg(border_color).bg(confirm_bg)),
388                    Span::styled(" ".repeat(1), Style::default().bg(confirm_bg)),
389                    Span::styled(
390                        confirm_msg,
391                        Style::default().fg(t.tool_confirm_text).bg(confirm_bg),
392                    ),
393                    Span::styled(
394                        " ".repeat(fill.saturating_sub(1).saturating_add(2)),
395                        Style::default().bg(confirm_bg),
396                    ),
397                    Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
398                ]));
399            }
400
401            // 空行
402            {
403                let fill = bubble_max_width.saturating_sub(4);
404                lines.push(Line::from(vec![
405                    Span::styled("  │", Style::default().fg(border_color).bg(confirm_bg)),
406                    Span::styled(" ".repeat(fill), Style::default().bg(confirm_bg)),
407                    Span::styled("│", Style::default().fg(border_color).bg(confirm_bg)),
408                ]));
409            }
410
411            // 操作提示行
412            {
413                let hint_text_w = display_width("[Y/Enter] 执行  /  [N/Esc] 拒绝");
414                let fill = content_w.saturating_sub(hint_text_w + 2);
415                lines.push(Line::from(vec![
416                    Span::styled("  │ ", Style::default().fg(border_color).bg(confirm_bg)),
417                    Span::styled(" ".repeat(1), Style::default().bg(confirm_bg)),
418                    Span::styled(
419                        "[Y/Enter] 执行",
420                        Style::default()
421                            .fg(t.toast_success_border)
422                            .bg(confirm_bg)
423                            .add_modifier(Modifier::BOLD),
424                    ),
425                    Span::styled(
426                        "  /  ",
427                        Style::default().fg(t.tool_confirm_label).bg(confirm_bg),
428                    ),
429                    Span::styled(
430                        "[N/Esc] 拒绝",
431                        Style::default()
432                            .fg(t.toast_error_border)
433                            .bg(confirm_bg)
434                            .add_modifier(Modifier::BOLD),
435                    ),
436                    Span::styled(
437                        " ".repeat(fill.saturating_sub(1).saturating_add(2)),
438                        Style::default().bg(confirm_bg),
439                    ),
440                    Span::styled(" │", Style::default().fg(border_color).bg(confirm_bg)),
441                ]));
442            }
443
444            // 底边框
445            let bottom_border = format!("  └{}┘", "─".repeat(bubble_max_width.saturating_sub(4)));
446            lines.push(Line::from(Span::styled(
447                bottom_border,
448                Style::default().fg(border_color).bg(confirm_bg),
449            )));
450        }
451    }
452
453    // 末尾留白
454    lines.push(Line::from(""));
455
456    // 计算最终的流式稳定缓存
457    let (final_stable_lines, final_stable_offset) = if let Some(sc) = &streaming_content_str {
458        let boundary = find_stable_boundary(sc);
459        let bubble_bg = t.bubble_ai;
460        let pad_left_w = 3usize;
461        let pad_right_w = 3usize;
462        let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
463        let bubble_total_w = bubble_max_width;
464
465        let (mut s_lines, s_offset) = if let Some(old_c) = old_cache {
466            if old_c.bubble_max_width == bubble_max_width {
467                (
468                    old_c.streaming_stable_lines.clone(),
469                    old_c.streaming_stable_offset,
470                )
471            } else {
472                (Vec::<Line<'static>>::new(), 0)
473            }
474        } else {
475            (Vec::<Line<'static>>::new(), 0)
476        };
477
478        if boundary > s_offset {
479            let new_text = &sc[s_offset..boundary];
480            let new_md_lines = markdown_to_lines(new_text, md_content_w + 2, t);
481            for md_line in new_md_lines {
482                let bubble_line = wrap_md_line_in_bubble(
483                    md_line,
484                    bubble_bg,
485                    pad_left_w,
486                    pad_right_w,
487                    bubble_total_w,
488                );
489                s_lines.push(bubble_line);
490            }
491        }
492        (s_lines, boundary)
493    } else {
494        (Vec::new(), 0)
495    };
496
497    (
498        lines,
499        msg_start_lines,
500        per_msg_cache,
501        final_stable_lines,
502        final_stable_offset,
503    )
504}
505
506/// 将一行 Markdown 渲染结果包装成气泡样式行(左右内边距 + 背景色 + 填充到统一宽度)
507pub fn wrap_md_line_in_bubble(
508    md_line: Line<'static>,
509    bubble_bg: Color,
510    pad_left_w: usize,
511    pad_right_w: usize,
512    bubble_total_w: usize,
513) -> Line<'static> {
514    let pad_left = " ".repeat(pad_left_w);
515    let pad_right = " ".repeat(pad_right_w);
516    let mut styled_spans: Vec<Span> = Vec::new();
517    styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
518    let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
519    let mut content_w: usize = 0;
520    for span in md_line.spans {
521        let sw = display_width(&span.content);
522        if content_w + sw > target_content_w {
523            // 安全钳制:逐字符截断以适应目标宽度
524            let remaining = target_content_w.saturating_sub(content_w);
525            if remaining > 0 {
526                let mut truncated = String::new();
527                let mut tw = 0;
528                for ch in span.content.chars() {
529                    let cw = char_width(ch);
530                    if tw + cw > remaining {
531                        break;
532                    }
533                    truncated.push(ch);
534                    tw += cw;
535                }
536                if !truncated.is_empty() {
537                    content_w += tw;
538                    let merged_style = span.style.bg(bubble_bg);
539                    styled_spans.push(Span::styled(truncated, merged_style));
540                }
541            }
542            // 跳过后续 span(已溢出)
543            break;
544        }
545        content_w += sw;
546        let merged_style = span.style.bg(bubble_bg);
547        styled_spans.push(Span::styled(span.content.to_string(), merged_style));
548    }
549    let fill = target_content_w.saturating_sub(content_w);
550    if fill > 0 {
551        styled_spans.push(Span::styled(
552            " ".repeat(fill),
553            Style::default().bg(bubble_bg),
554        ));
555    }
556    styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
557    Line::from(styled_spans)
558}
559
560/// 渲染用户消息
561pub fn render_user_msg(
562    content: &str,
563    is_selected: bool,
564    inner_width: usize,
565    bubble_max_width: usize,
566    lines: &mut Vec<Line<'static>>,
567    theme: &Theme,
568) {
569    lines.push(Line::from(""));
570    let label = if is_selected { "▶ You " } else { "You " };
571    let pad = inner_width.saturating_sub(display_width(label) + 2);
572    lines.push(Line::from(vec![
573        Span::raw(" ".repeat(pad)),
574        Span::styled(
575            label,
576            Style::default()
577                .fg(if is_selected {
578                    theme.label_selected
579                } else {
580                    theme.label_user
581                })
582                .add_modifier(Modifier::BOLD),
583        ),
584    ]));
585    let user_bg = if is_selected {
586        theme.bubble_user_selected
587    } else {
588        theme.bubble_user
589    };
590    let user_pad_lr = 3usize;
591    let user_content_w = bubble_max_width.saturating_sub(user_pad_lr * 2);
592    let mut all_wrapped_lines: Vec<String> = Vec::new();
593    for content_line in content.lines() {
594        let wrapped = wrap_text(content_line, user_content_w);
595        all_wrapped_lines.extend(wrapped);
596    }
597    if all_wrapped_lines.is_empty() {
598        all_wrapped_lines.push(String::new());
599    }
600    let actual_content_w = all_wrapped_lines
601        .iter()
602        .map(|l| display_width(l))
603        .max()
604        .unwrap_or(0);
605    let actual_bubble_w = (actual_content_w + user_pad_lr * 2)
606        .min(bubble_max_width)
607        .max(user_pad_lr * 2 + 1);
608    let actual_inner_content_w = actual_bubble_w.saturating_sub(user_pad_lr * 2);
609    // 上边距
610    {
611        let bubble_text = " ".repeat(actual_bubble_w);
612        let pad = inner_width.saturating_sub(actual_bubble_w);
613        lines.push(Line::from(vec![
614            Span::raw(" ".repeat(pad)),
615            Span::styled(bubble_text, Style::default().bg(user_bg)),
616        ]));
617    }
618    for wl in &all_wrapped_lines {
619        let wl_width = display_width(wl);
620        let fill = actual_inner_content_w.saturating_sub(wl_width);
621        let text = format!(
622            "{}{}{}{}",
623            " ".repeat(user_pad_lr),
624            wl,
625            " ".repeat(fill),
626            " ".repeat(user_pad_lr),
627        );
628        let text_width = display_width(&text);
629        let pad = inner_width.saturating_sub(text_width);
630        lines.push(Line::from(vec![
631            Span::raw(" ".repeat(pad)),
632            Span::styled(text, Style::default().fg(theme.text_white).bg(user_bg)),
633        ]));
634    }
635    // 下边距
636    {
637        let bubble_text = " ".repeat(actual_bubble_w);
638        let pad = inner_width.saturating_sub(actual_bubble_w);
639        lines.push(Line::from(vec![
640            Span::raw(" ".repeat(pad)),
641            Span::styled(bubble_text, Style::default().bg(user_bg)),
642        ]));
643    }
644}
645
646/// 渲染 AI 助手消息
647pub fn render_assistant_msg(
648    content: &str,
649    is_selected: bool,
650    bubble_max_width: usize,
651    lines: &mut Vec<Line<'static>>,
652    theme: &Theme,
653) {
654    lines.push(Line::from(""));
655    let ai_label = if is_selected { "  ▶ AI" } else { "  AI" };
656    lines.push(Line::from(Span::styled(
657        ai_label,
658        Style::default()
659            .fg(if is_selected {
660                theme.label_selected
661            } else {
662                theme.label_ai
663            })
664            .add_modifier(Modifier::BOLD),
665    )));
666    let bubble_bg = if is_selected {
667        theme.bubble_ai_selected
668    } else {
669        theme.bubble_ai
670    };
671    let pad_left_w = 3usize;
672    let pad_right_w = 3usize;
673    let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
674    let md_lines = markdown_to_lines(content, md_content_w + 2, theme);
675    let bubble_total_w = bubble_max_width;
676    // 上边距
677    lines.push(Line::from(vec![Span::styled(
678        " ".repeat(bubble_total_w),
679        Style::default().bg(bubble_bg),
680    )]));
681    for md_line in md_lines {
682        let bubble_line =
683            wrap_md_line_in_bubble(md_line, bubble_bg, pad_left_w, pad_right_w, bubble_total_w);
684        lines.push(bubble_line);
685    }
686    // 下边距
687    lines.push(Line::from(vec![Span::styled(
688        " ".repeat(bubble_total_w),
689        Style::default().bg(bubble_bg),
690    )]));
691}
692
693/// 将 Markdown 文本解析为 ratatui 的 Line 列表
694/// 支持:标题(去掉 # 标记)、加粗、斜体、行内代码、代码块(语法高亮)、列表、分隔线
695/// content_width:内容区可用宽度(不含外层 "  " 缩进和右侧填充)
696
697pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
698    // 最小宽度保证至少能放下一个字符(中文字符宽度2),避免无限循环或不截断
699    let max_width = max_width.max(2);
700    let mut result = Vec::new();
701    let mut current_line = String::new();
702    let mut current_width = 0;
703
704    for ch in text.chars() {
705        let ch_width = char_width(ch);
706        if current_width + ch_width > max_width && !current_line.is_empty() {
707            result.push(current_line.clone());
708            current_line.clear();
709            current_width = 0;
710        }
711        current_line.push(ch);
712        current_width += ch_width;
713    }
714    if !current_line.is_empty() {
715        result.push(current_line);
716    }
717    if result.is_empty() {
718        result.push(String::new());
719    }
720    result
721}
722
723/// 计算字符串的显示宽度(使用 unicode-width crate,比手动范围匹配更准确)
724pub fn display_width(s: &str) -> usize {
725    use unicode_width::UnicodeWidthStr;
726    UnicodeWidthStr::width(s)
727}
728
729/// 计算单个字符的显示宽度(使用 unicode-width crate)
730pub fn char_width(c: char) -> usize {
731    use unicode_width::UnicodeWidthChar;
732    UnicodeWidthChar::width(c).unwrap_or(0)
733}
734
735/// 渲染工具调用请求消息(AI 发起):黄色标签 + 工具名和参数摘要
736pub fn render_tool_call_request_msg(
737    tool_calls: &[super::model::ToolCallItem],
738    bubble_max_width: usize,
739    lines: &mut Vec<Line<'static>>,
740    theme: &Theme,
741) {
742    lines.push(Line::from(""));
743    lines.push(Line::from(Span::styled(
744        "  🔧 AI 调用工具",
745        Style::default()
746            .fg(Color::Yellow)
747            .add_modifier(Modifier::BOLD),
748    )));
749    let bubble_bg = Color::Rgb(40, 35, 10);
750    let pad = 3usize;
751    let content_w = bubble_max_width.saturating_sub(pad * 2);
752    lines.push(Line::from(vec![Span::styled(
753        " ".repeat(bubble_max_width),
754        Style::default().bg(bubble_bg),
755    )]));
756    for tc in tool_calls {
757        let args_preview: String = tc.arguments.chars().take(50).collect();
758        let args_display = if tc.arguments.len() > 50 {
759            format!("{}...", args_preview)
760        } else {
761            args_preview
762        };
763        let text = format!("{} ({})", tc.name, args_display);
764        let wrapped = wrap_text(&text, content_w);
765        for wl in wrapped {
766            let fill = content_w.saturating_sub(display_width(&wl));
767            lines.push(Line::from(vec![
768                Span::styled(" ".repeat(pad), Style::default().bg(bubble_bg)),
769                Span::styled(wl, Style::default().fg(Color::Yellow).bg(bubble_bg)),
770                Span::styled(" ".repeat(fill), Style::default().bg(bubble_bg)),
771                Span::styled(" ".repeat(pad), Style::default().bg(bubble_bg)),
772            ]));
773        }
774    }
775    let _ = theme; // 保留参数以便未来扩展
776    lines.push(Line::from(vec![Span::styled(
777        " ".repeat(bubble_max_width),
778        Style::default().bg(bubble_bg),
779    )]));
780}
781
782/// 渲染工具执行结果消息:绿色标签 + 截断内容(最多 5 行)
783pub fn render_tool_result_msg(
784    content: &str,
785    label: &str,
786    lines: &mut Vec<Line<'static>>,
787    theme: &Theme,
788) {
789    lines.push(Line::from(""));
790    lines.push(Line::from(Span::styled(
791        format!("  ✅ {}", label),
792        Style::default()
793            .fg(Color::Green)
794            .add_modifier(Modifier::BOLD),
795    )));
796    let bubble_bg = Color::Rgb(10, 40, 15);
797    let pad = 3usize;
798    let content_w = 60usize;
799    let bubble_w = content_w + pad * 2;
800    lines.push(Line::from(vec![Span::styled(
801        " ".repeat(bubble_w),
802        Style::default().bg(bubble_bg),
803    )]));
804    let display_content = if content.len() > 200 {
805        let mut end = 200;
806        while !content.is_char_boundary(end) {
807            end -= 1;
808        }
809        format!("{}...", &content[..end])
810    } else {
811        content.to_string()
812    };
813    let all_lines: Vec<String> = display_content
814        .lines()
815        .flat_map(|l| wrap_text(l, content_w))
816        .take(5)
817        .collect();
818    for wl in all_lines {
819        let fill = content_w.saturating_sub(display_width(&wl));
820        lines.push(Line::from(vec![
821            Span::styled(" ".repeat(pad), Style::default().bg(bubble_bg)),
822            Span::styled(
823                wl,
824                Style::default().fg(Color::Rgb(180, 255, 180)).bg(bubble_bg),
825            ),
826            Span::styled(" ".repeat(fill), Style::default().bg(bubble_bg)),
827            Span::styled(" ".repeat(pad), Style::default().bg(bubble_bg)),
828        ]));
829    }
830    let _ = theme;
831    lines.push(Line::from(vec![Span::styled(
832        " ".repeat(bubble_w),
833        Style::default().bg(bubble_bg),
834    )]));
835}
836
837pub fn copy_to_clipboard(content: &str) -> bool {
838    use std::process::{Command, Stdio};
839
840    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
841        ("pbcopy", vec![])
842    } else if cfg!(target_os = "linux") {
843        if Command::new("which")
844            .arg("xclip")
845            .output()
846            .map(|o| o.status.success())
847            .unwrap_or(false)
848        {
849            ("xclip", vec!["-selection", "clipboard"])
850        } else {
851            ("xsel", vec!["--clipboard", "--input"])
852        }
853    } else {
854        return false;
855    };
856
857    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
858
859    match child {
860        Ok(mut child) => {
861            if let Some(ref mut stdin) = child.stdin {
862                let _ = stdin.write_all(content.as_bytes());
863            }
864            child.wait().map(|s| s.success()).unwrap_or(false)
865        }
866        Err(_) => false,
867    }
868}