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    }
62    let mut render_msgs: Vec<RenderMsg> = app
63        .session
64        .messages
65        .iter()
66        .enumerate()
67        .map(|(i, m)| RenderMsg {
68            role: m.role.clone(),
69            content: m.content.clone(),
70            msg_index: Some(i),
71        })
72        .collect();
73
74    // 如果正在流式接收,添加一条临时的 assistant 消息
75    let streaming_content_str = if app.is_loading {
76        let streaming = app.streaming_content.lock().unwrap().clone();
77        if !streaming.is_empty() {
78            render_msgs.push(RenderMsg {
79                role: "assistant".to_string(),
80                content: streaming.clone(),
81                msg_index: None,
82            });
83            Some(streaming)
84        } else {
85            render_msgs.push(RenderMsg {
86                role: "assistant".to_string(),
87                content: "◍".to_string(),
88                msg_index: None,
89            });
90            None
91        }
92    } else {
93        None
94    };
95
96    let t = &app.theme;
97    let is_browse_mode = app.mode == ChatMode::Browse;
98    let mut lines: Vec<Line> = Vec::new();
99    let mut msg_start_lines: Vec<(usize, usize)> = Vec::new();
100    let mut per_msg_cache: Vec<PerMsgCache> = Vec::new();
101
102    // 判断旧缓存中的 per_msg_lines 是否可以复用(bubble_max_width 相同且浏览模式状态一致)
103    let can_reuse_per_msg = old_cache
104        .map(|c| c.bubble_max_width == bubble_max_width)
105        .unwrap_or(false);
106
107    for msg in &render_msgs {
108        let is_selected = is_browse_mode
109            && msg.msg_index.is_some()
110            && msg.msg_index.unwrap() == app.browse_msg_index;
111
112        // 记录消息起始行号
113        if let Some(idx) = msg.msg_index {
114            msg_start_lines.push((idx, lines.len()));
115        }
116
117        // P0 优化:对于有 msg_index 的历史消息,尝试复用旧缓存
118        if let Some(idx) = msg.msg_index {
119            if can_reuse_per_msg {
120                if let Some(old_c) = old_cache {
121                    // 查找旧缓存中同索引的消息
122                    if let Some(old_per) = old_c.per_msg_lines.iter().find(|p| p.msg_index == idx) {
123                        // 内容长度相同 → 消息内容未变,且浏览选中状态一致
124                        let old_was_selected = old_c.browse_index == Some(idx);
125                        if old_per.content_len == msg.content.len()
126                            && old_was_selected == is_selected
127                        {
128                            // 直接复用旧缓存的渲染行
129                            lines.extend(old_per.lines.iter().cloned());
130                            per_msg_cache.push(PerMsgCache {
131                                content_len: old_per.content_len,
132                                lines: old_per.lines.clone(),
133                                msg_index: idx,
134                            });
135                            continue;
136                        }
137                    }
138                }
139            }
140        }
141
142        // 缓存未命中 / 流式消息 → 重新渲染
143        let msg_lines_start = lines.len();
144        match msg.role.as_str() {
145            "user" => {
146                render_user_msg(
147                    &msg.content,
148                    is_selected,
149                    inner_width,
150                    bubble_max_width,
151                    &mut lines,
152                    t,
153                );
154            }
155            "assistant" => {
156                if msg.msg_index.is_none() {
157                    // 流式消息:P1 增量段落渲染(在后面单独处理)
158                    // 这里先跳过,后面统一处理
159                    // 先标记位置
160                } else {
161                    // 已完成的 assistant 消息:完整 Markdown 渲染
162                    render_assistant_msg(
163                        &msg.content,
164                        is_selected,
165                        bubble_max_width,
166                        &mut lines,
167                        t,
168                    );
169                }
170            }
171            "system" => {
172                lines.push(Line::from(""));
173                let wrapped = wrap_text(&msg.content, inner_width.saturating_sub(8));
174                for wl in wrapped {
175                    lines.push(Line::from(Span::styled(
176                        format!("    {}  {}", "sys", wl),
177                        Style::default().fg(t.text_system),
178                    )));
179                }
180            }
181            _ => {}
182        }
183
184        // 流式消息的渲染在 assistant 分支中被跳过了,这里处理
185        if msg.role == "assistant" && msg.msg_index.is_none() {
186            // P1 增量段落渲染
187            let bubble_bg = t.bubble_ai;
188            let pad_left_w = 3usize;
189            let pad_right_w = 3usize;
190            let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
191            let bubble_total_w = bubble_max_width;
192
193            // AI 标签
194            lines.push(Line::from(""));
195            lines.push(Line::from(Span::styled(
196                "  AI",
197                Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD),
198            )));
199
200            // 上边距
201            lines.push(Line::from(vec![Span::styled(
202                " ".repeat(bubble_total_w),
203                Style::default().bg(bubble_bg),
204            )]));
205
206            // 增量段落渲染:取旧缓存中的 stable_lines 和 stable_offset
207            let (mut stable_lines, mut stable_offset) = if let Some(old_c) = old_cache {
208                if old_c.bubble_max_width == bubble_max_width {
209                    (
210                        old_c.streaming_stable_lines.clone(),
211                        old_c.streaming_stable_offset,
212                    )
213                } else {
214                    (Vec::<Line<'static>>::new(), 0)
215                }
216            } else {
217                (Vec::<Line<'static>>::new(), 0)
218            };
219
220            let content = &msg.content;
221            // 找到当前内容中最后一个安全的段落边界
222            let boundary = find_stable_boundary(content);
223
224            // 如果有新的完整段落超过了上次缓存的偏移
225            if boundary > stable_offset {
226                // 增量解析:从上次偏移到新边界的新完成段落
227                let new_stable_text = &content[stable_offset..boundary];
228                let new_md_lines = markdown_to_lines(new_stable_text, md_content_w + 2, t);
229                // 将新段落的渲染行包装成气泡样式并追加到 stable_lines
230                for md_line in new_md_lines {
231                    let bubble_line = wrap_md_line_in_bubble(
232                        md_line,
233                        bubble_bg,
234                        pad_left_w,
235                        pad_right_w,
236                        bubble_total_w,
237                    );
238                    stable_lines.push(bubble_line);
239                }
240                stable_offset = boundary;
241            }
242
243            // 追加已缓存的稳定段落行
244            lines.extend(stable_lines.iter().cloned());
245
246            // 只对最后一个不完整段落做全量 Markdown 解析
247            let tail = &content[boundary..];
248            if !tail.is_empty() {
249                let tail_md_lines = markdown_to_lines(tail, md_content_w + 2, t);
250                for md_line in tail_md_lines {
251                    let bubble_line = wrap_md_line_in_bubble(
252                        md_line,
253                        bubble_bg,
254                        pad_left_w,
255                        pad_right_w,
256                        bubble_total_w,
257                    );
258                    lines.push(bubble_line);
259                }
260            }
261
262            // 下边距
263            lines.push(Line::from(vec![Span::styled(
264                " ".repeat(bubble_total_w),
265                Style::default().bg(bubble_bg),
266            )]));
267
268            // 记录最终的 stable 状态用于返回
269            // (在函数末尾统一返回)
270            // 先用局部变量暂存
271            let _ = (stable_lines.clone(), stable_offset);
272
273            // 构建末尾留白和返回值时统一处理
274        } else if let Some(idx) = msg.msg_index {
275            // 缓存此历史消息的渲染行
276            let msg_lines_end = lines.len();
277            let this_msg_lines: Vec<Line<'static>> = lines[msg_lines_start..msg_lines_end].to_vec();
278            per_msg_cache.push(PerMsgCache {
279                content_len: msg.content.len(),
280                lines: this_msg_lines,
281                msg_index: idx,
282            });
283        }
284    }
285
286    // 末尾留白
287    lines.push(Line::from(""));
288
289    // 计算最终的流式稳定缓存
290    let (final_stable_lines, final_stable_offset) = if let Some(sc) = &streaming_content_str {
291        let boundary = find_stable_boundary(sc);
292        let bubble_bg = t.bubble_ai;
293        let pad_left_w = 3usize;
294        let pad_right_w = 3usize;
295        let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
296        let bubble_total_w = bubble_max_width;
297
298        let (mut s_lines, s_offset) = if let Some(old_c) = old_cache {
299            if old_c.bubble_max_width == bubble_max_width {
300                (
301                    old_c.streaming_stable_lines.clone(),
302                    old_c.streaming_stable_offset,
303                )
304            } else {
305                (Vec::<Line<'static>>::new(), 0)
306            }
307        } else {
308            (Vec::<Line<'static>>::new(), 0)
309        };
310
311        if boundary > s_offset {
312            let new_text = &sc[s_offset..boundary];
313            let new_md_lines = markdown_to_lines(new_text, md_content_w + 2, t);
314            for md_line in new_md_lines {
315                let bubble_line = wrap_md_line_in_bubble(
316                    md_line,
317                    bubble_bg,
318                    pad_left_w,
319                    pad_right_w,
320                    bubble_total_w,
321                );
322                s_lines.push(bubble_line);
323            }
324        }
325        (s_lines, boundary)
326    } else {
327        (Vec::new(), 0)
328    };
329
330    (
331        lines,
332        msg_start_lines,
333        per_msg_cache,
334        final_stable_lines,
335        final_stable_offset,
336    )
337}
338
339/// 将一行 Markdown 渲染结果包装成气泡样式行(左右内边距 + 背景色 + 填充到统一宽度)
340pub fn wrap_md_line_in_bubble(
341    md_line: Line<'static>,
342    bubble_bg: Color,
343    pad_left_w: usize,
344    pad_right_w: usize,
345    bubble_total_w: usize,
346) -> Line<'static> {
347    let pad_left = " ".repeat(pad_left_w);
348    let pad_right = " ".repeat(pad_right_w);
349    let mut styled_spans: Vec<Span> = Vec::new();
350    styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
351    let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
352    let mut content_w: usize = 0;
353    for span in md_line.spans {
354        let sw = display_width(&span.content);
355        if content_w + sw > target_content_w {
356            // 安全钳制:逐字符截断以适应目标宽度
357            let remaining = target_content_w.saturating_sub(content_w);
358            if remaining > 0 {
359                let mut truncated = String::new();
360                let mut tw = 0;
361                for ch in span.content.chars() {
362                    let cw = char_width(ch);
363                    if tw + cw > remaining {
364                        break;
365                    }
366                    truncated.push(ch);
367                    tw += cw;
368                }
369                if !truncated.is_empty() {
370                    content_w += tw;
371                    let merged_style = span.style.bg(bubble_bg);
372                    styled_spans.push(Span::styled(truncated, merged_style));
373                }
374            }
375            // 跳过后续 span(已溢出)
376            break;
377        }
378        content_w += sw;
379        let merged_style = span.style.bg(bubble_bg);
380        styled_spans.push(Span::styled(span.content.to_string(), merged_style));
381    }
382    let fill = target_content_w.saturating_sub(content_w);
383    if fill > 0 {
384        styled_spans.push(Span::styled(
385            " ".repeat(fill),
386            Style::default().bg(bubble_bg),
387        ));
388    }
389    styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
390    Line::from(styled_spans)
391}
392
393/// 渲染用户消息
394pub fn render_user_msg(
395    content: &str,
396    is_selected: bool,
397    inner_width: usize,
398    bubble_max_width: usize,
399    lines: &mut Vec<Line<'static>>,
400    theme: &Theme,
401) {
402    lines.push(Line::from(""));
403    let label = if is_selected { "▶ You " } else { "You " };
404    let pad = inner_width.saturating_sub(display_width(label) + 2);
405    lines.push(Line::from(vec![
406        Span::raw(" ".repeat(pad)),
407        Span::styled(
408            label,
409            Style::default()
410                .fg(if is_selected {
411                    theme.label_selected
412                } else {
413                    theme.label_user
414                })
415                .add_modifier(Modifier::BOLD),
416        ),
417    ]));
418    let user_bg = if is_selected {
419        theme.bubble_user_selected
420    } else {
421        theme.bubble_user
422    };
423    let user_pad_lr = 3usize;
424    let user_content_w = bubble_max_width.saturating_sub(user_pad_lr * 2);
425    let mut all_wrapped_lines: Vec<String> = Vec::new();
426    for content_line in content.lines() {
427        let wrapped = wrap_text(content_line, user_content_w);
428        all_wrapped_lines.extend(wrapped);
429    }
430    if all_wrapped_lines.is_empty() {
431        all_wrapped_lines.push(String::new());
432    }
433    let actual_content_w = all_wrapped_lines
434        .iter()
435        .map(|l| display_width(l))
436        .max()
437        .unwrap_or(0);
438    let actual_bubble_w = (actual_content_w + user_pad_lr * 2)
439        .min(bubble_max_width)
440        .max(user_pad_lr * 2 + 1);
441    let actual_inner_content_w = actual_bubble_w.saturating_sub(user_pad_lr * 2);
442    // 上边距
443    {
444        let bubble_text = " ".repeat(actual_bubble_w);
445        let pad = inner_width.saturating_sub(actual_bubble_w);
446        lines.push(Line::from(vec![
447            Span::raw(" ".repeat(pad)),
448            Span::styled(bubble_text, Style::default().bg(user_bg)),
449        ]));
450    }
451    for wl in &all_wrapped_lines {
452        let wl_width = display_width(wl);
453        let fill = actual_inner_content_w.saturating_sub(wl_width);
454        let text = format!(
455            "{}{}{}{}",
456            " ".repeat(user_pad_lr),
457            wl,
458            " ".repeat(fill),
459            " ".repeat(user_pad_lr),
460        );
461        let text_width = display_width(&text);
462        let pad = inner_width.saturating_sub(text_width);
463        lines.push(Line::from(vec![
464            Span::raw(" ".repeat(pad)),
465            Span::styled(text, Style::default().fg(theme.text_white).bg(user_bg)),
466        ]));
467    }
468    // 下边距
469    {
470        let bubble_text = " ".repeat(actual_bubble_w);
471        let pad = inner_width.saturating_sub(actual_bubble_w);
472        lines.push(Line::from(vec![
473            Span::raw(" ".repeat(pad)),
474            Span::styled(bubble_text, Style::default().bg(user_bg)),
475        ]));
476    }
477}
478
479/// 渲染 AI 助手消息
480pub fn render_assistant_msg(
481    content: &str,
482    is_selected: bool,
483    bubble_max_width: usize,
484    lines: &mut Vec<Line<'static>>,
485    theme: &Theme,
486) {
487    lines.push(Line::from(""));
488    let ai_label = if is_selected { "  ▶ AI" } else { "  AI" };
489    lines.push(Line::from(Span::styled(
490        ai_label,
491        Style::default()
492            .fg(if is_selected {
493                theme.label_selected
494            } else {
495                theme.label_ai
496            })
497            .add_modifier(Modifier::BOLD),
498    )));
499    let bubble_bg = if is_selected {
500        theme.bubble_ai_selected
501    } else {
502        theme.bubble_ai
503    };
504    let pad_left_w = 3usize;
505    let pad_right_w = 3usize;
506    let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
507    let md_lines = markdown_to_lines(content, md_content_w + 2, theme);
508    let bubble_total_w = bubble_max_width;
509    // 上边距
510    lines.push(Line::from(vec![Span::styled(
511        " ".repeat(bubble_total_w),
512        Style::default().bg(bubble_bg),
513    )]));
514    for md_line in md_lines {
515        let bubble_line =
516            wrap_md_line_in_bubble(md_line, bubble_bg, pad_left_w, pad_right_w, bubble_total_w);
517        lines.push(bubble_line);
518    }
519    // 下边距
520    lines.push(Line::from(vec![Span::styled(
521        " ".repeat(bubble_total_w),
522        Style::default().bg(bubble_bg),
523    )]));
524}
525
526/// 将 Markdown 文本解析为 ratatui 的 Line 列表
527/// 支持:标题(去掉 # 标记)、加粗、斜体、行内代码、代码块(语法高亮)、列表、分隔线
528/// content_width:内容区可用宽度(不含外层 "  " 缩进和右侧填充)
529
530pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
531    // 最小宽度保证至少能放下一个字符(中文字符宽度2),避免无限循环或不截断
532    let max_width = max_width.max(2);
533    let mut result = Vec::new();
534    let mut current_line = String::new();
535    let mut current_width = 0;
536
537    for ch in text.chars() {
538        let ch_width = char_width(ch);
539        if current_width + ch_width > max_width && !current_line.is_empty() {
540            result.push(current_line.clone());
541            current_line.clear();
542            current_width = 0;
543        }
544        current_line.push(ch);
545        current_width += ch_width;
546    }
547    if !current_line.is_empty() {
548        result.push(current_line);
549    }
550    if result.is_empty() {
551        result.push(String::new());
552    }
553    result
554}
555
556/// 计算字符串的显示宽度(使用 unicode-width crate,比手动范围匹配更准确)
557pub fn display_width(s: &str) -> usize {
558    use unicode_width::UnicodeWidthStr;
559    UnicodeWidthStr::width(s)
560}
561
562/// 计算单个字符的显示宽度(使用 unicode-width crate)
563pub fn char_width(c: char) -> usize {
564    use unicode_width::UnicodeWidthChar;
565    UnicodeWidthChar::width(c).unwrap_or(0)
566}
567
568pub fn copy_to_clipboard(content: &str) -> bool {
569    use std::process::{Command, Stdio};
570
571    let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
572        ("pbcopy", vec![])
573    } else if cfg!(target_os = "linux") {
574        if Command::new("which")
575            .arg("xclip")
576            .output()
577            .map(|o| o.status.success())
578            .unwrap_or(false)
579        {
580            ("xclip", vec!["-selection", "clipboard"])
581        } else {
582            ("xsel", vec!["--clipboard", "--input"])
583        }
584    } else {
585        return false;
586    };
587
588    let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
589
590    match child {
591        Ok(mut child) => {
592            if let Some(ref mut stdin) = child.stdin {
593                let _ = stdin.write_all(content.as_bytes());
594            }
595            child.wait().map(|s| s.success()).unwrap_or(false)
596        }
597        Err(_) => false,
598    }
599}