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 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 if i + 2 < bytes.len() && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
18 fence_count += 1;
19 i += 3;
20 while i < bytes.len() && bytes[i] != b'\n' {
22 i += 1;
23 }
24 continue;
25 }
26 if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
28 if fence_count % 2 == 0 {
30 last_safe_boundary = i + 2; }
32 i += 2;
33 continue;
34 }
35 i += 1;
36 }
37 last_safe_boundary
38}
39
40pub 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 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 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 if let Some(idx) = msg.msg_index {
112 msg_start_lines.push((idx, lines.len()));
113 }
114
115 if let Some(idx) = msg.msg_index {
117 if can_reuse_per_msg {
118 if let Some(old_c) = old_cache {
119 if let Some(old_per) = old_c.per_msg_lines.iter().find(|p| p.msg_index == idx) {
121 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 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 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 } else {
158 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 if msg.role == "assistant" && msg.msg_index.is_none() {
177 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 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 lines.push(Line::from(vec![Span::styled(
195 " ".repeat(bubble_total_w),
196 Style::default().bg(bubble_bg),
197 )]));
198
199 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 let boundary = find_stable_boundary(content);
216
217 if boundary > stable_offset {
219 let new_stable_text = &content[stable_offset..boundary];
221 let new_md_lines = markdown_to_lines(new_stable_text, md_content_w + 2);
222 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 lines.extend(stable_lines.iter().cloned());
238
239 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 lines.push(Line::from(vec![Span::styled(
257 " ".repeat(bubble_total_w),
258 Style::default().bg(bubble_bg),
259 )]));
260
261 let _ = (stable_lines.clone(), stable_offset);
265
266 } else if let Some(idx) = msg.msg_index {
268 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 lines.push(Line::from(""));
281
282 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
332pub 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 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 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
386pub 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 {
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 {
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
471pub 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 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 lines.push(Line::from(vec![Span::styled(
512 " ".repeat(bubble_total_w),
513 Style::default().bg(bubble_bg),
514 )]));
515}
516
517pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
522 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
547pub fn display_width(s: &str) -> usize {
549 use unicode_width::UnicodeWidthStr;
550 UnicodeWidthStr::width(s)
551}
552
553pub 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}