Skip to main content

j_cli/command/chat/
render.rs

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