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 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 if i + 2 < bytes.len() && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
19 fence_count += 1;
20 i += 3;
21 while i < bytes.len() && bytes[i] != b'\n' {
23 i += 1;
24 }
25 continue;
26 }
27 if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
29 if fence_count % 2 == 0 {
31 last_safe_boundary = i + 2; }
33 i += 2;
34 continue;
35 }
36 i += 1;
37 }
38 last_safe_boundary
39}
40
41pub 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 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 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 if let Some(idx) = msg.msg_index {
114 msg_start_lines.push((idx, lines.len()));
115 }
116
117 if let Some(idx) = msg.msg_index {
119 if can_reuse_per_msg {
120 if let Some(old_c) = old_cache {
121 if let Some(old_per) = old_c.per_msg_lines.iter().find(|p| p.msg_index == idx) {
123 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 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 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 } else {
161 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 if msg.role == "assistant" && msg.msg_index.is_none() {
186 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 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 lines.push(Line::from(vec![Span::styled(
202 " ".repeat(bubble_total_w),
203 Style::default().bg(bubble_bg),
204 )]));
205
206 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 let boundary = find_stable_boundary(content);
223
224 if boundary > stable_offset {
226 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 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 lines.extend(stable_lines.iter().cloned());
245
246 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 lines.push(Line::from(vec![Span::styled(
264 " ".repeat(bubble_total_w),
265 Style::default().bg(bubble_bg),
266 )]));
267
268 let _ = (stable_lines.clone(), stable_offset);
272
273 } else if let Some(idx) = msg.msg_index {
275 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 lines.push(Line::from(""));
288
289 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
339pub 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 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 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
393pub 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 {
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 {
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
479pub 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 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 lines.push(Line::from(vec![Span::styled(
521 " ".repeat(bubble_total_w),
522 Style::default().bg(bubble_bg),
523 )]));
524}
525
526pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
531 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
556pub fn display_width(s: &str) -> usize {
558 use unicode_width::UnicodeWidthStr;
559 UnicodeWidthStr::width(s)
560}
561
562pub 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}