1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Modifier, Style},
5 text::{Line, Span},
6 widgets::{Paragraph, Widget, Wrap},
7};
8
9use ratatui::layout::Alignment;
10
11use crate::state::{ConversationState, DiffLineKind, ToolUseStatus};
12use crate::theme;
13use crate::widgets::spinner::FRAMES;
14
15pub struct MessageList<'a> {
16 pub state: &'a mut ConversationState,
17 pub spinner_frame: usize,
18 pub tool_details: bool,
19}
20
21impl<'a> MessageList<'a> {
22 pub fn new(state: &'a mut ConversationState, spinner_frame: usize) -> Self {
23 Self { state, spinner_frame, tool_details: true }
24 }
25
26 pub fn with_tool_details(mut self, on: bool) -> Self {
27 self.tool_details = on;
28 self
29 }
30}
31
32fn parse_inline(text: &str) -> Vec<Span<'static>> {
33 let mut spans: Vec<Span<'static>> = Vec::new();
34 let mut buf = String::new();
35 let chars: Vec<char> = text.chars().collect();
36 let mut i = 0;
37 while i < chars.len() {
38 if i + 1 < chars.len() && chars[i] == '*' && chars[i+1] == '*' {
39 if !buf.is_empty() { spans.push(Span::raw(std::mem::take(&mut buf))); }
40 i += 2;
41 while i < chars.len() && !(i + 1 < chars.len() && chars[i] == '*' && chars[i+1] == '*') {
42 buf.push(chars[i]); i += 1;
43 }
44 spans.push(Span::styled(std::mem::take(&mut buf), Style::default().fg(theme::ROSE()).add_modifier(Modifier::BOLD)));
45 i += 2;
46 } else if chars[i] == '`' {
47 if !buf.is_empty() { spans.push(Span::raw(std::mem::take(&mut buf))); }
48 i += 1;
49 while i < chars.len() && chars[i] != '`' { buf.push(chars[i]); i += 1; }
50 spans.push(Span::styled(std::mem::take(&mut buf), Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)));
51 if i < chars.len() { i += 1; }
52 } else if chars[i] == '*' || chars[i] == '_' {
53 let delim = chars[i];
54 if !buf.is_empty() { spans.push(Span::raw(std::mem::take(&mut buf))); }
55 i += 1;
56 while i < chars.len() && chars[i] != delim { buf.push(chars[i]); i += 1; }
57 spans.push(Span::styled(std::mem::take(&mut buf), Style::default().fg(theme::SUBTLE()).add_modifier(Modifier::ITALIC)));
58 if i < chars.len() { i += 1; }
59 } else {
60 buf.push(chars[i]); i += 1;
61 }
62 }
63 if !buf.is_empty() { spans.push(Span::styled(buf, Style::default().fg(theme::TEXT()))); }
64 spans
65}
66
67fn is_hr(s: &str) -> bool {
68 let t = s.trim();
69 (t.starts_with("---") || t.starts_with("===") || t.starts_with("***"))
70 && t.chars().collect::<std::collections::HashSet<_>>().len() == 1
71}
72
73fn is_table_row(s: &str) -> bool {
74 let t = s.trim();
75 t.starts_with('|') && t.ends_with('|')
76}
77
78fn is_table_sep(s: &str) -> bool {
79 let t = s.trim();
80 is_table_row(t) && t.chars().all(|c| c == '|' || c == '-' || c == ':' || c == ' ')
81}
82
83fn render_table_row(raw: &str) -> Line<'static> {
84 let cells: Vec<&str> = raw.trim().trim_matches('|').split('|').collect();
85 let mut spans = vec![Span::styled(" ", Style::default())];
86 for (i, cell) in cells.iter().enumerate() {
87 if i > 0 {
88 spans.push(Span::styled(" │ ", Style::default().fg(theme::OVERLAY())));
89 }
90 let trimmed = cell.trim().to_string();
91 spans.push(Span::styled(trimmed, Style::default().fg(theme::TEXT())));
92 }
93 Line::from(spans)
94}
95
96fn render_md_line(raw: &str, in_code: bool) -> Line<'static> {
97 let trimmed = raw.trim_end();
98 if in_code {
99 return Line::from(vec![
100 Span::styled(" │ ", Style::default().fg(theme::OVERLAY())),
101 Span::styled(trimmed.to_string(), Style::default().fg(theme::GOLD())),
102 ]);
103 }
104 if is_hr(trimmed) {
105 return Line::from(Span::styled(
106 " ────────────────────────────────────────",
107 Style::default().fg(theme::OVERLAY()),
108 ));
109 }
110 if is_table_sep(trimmed) {
111 return Line::from(Span::styled(
112 " ─────────────────────────────────────────",
113 Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM),
114 ));
115 }
116 if is_table_row(trimmed) {
117 return render_table_row(trimmed);
118 }
119 if let Some(r) = trimmed.strip_prefix("### ") {
120 return Line::from(Span::styled(format!(" {r}"), Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)));
121 }
122 if let Some(r) = trimmed.strip_prefix("## ") {
123 return Line::from(Span::styled(format!(" {r}"), Style::default().fg(theme::ROSE()).add_modifier(Modifier::BOLD)));
124 }
125 if let Some(r) = trimmed.strip_prefix("# ") {
126 return Line::from(Span::styled(format!(" {r}"), Style::default().fg(theme::GOLD()).add_modifier(Modifier::BOLD)));
127 }
128 let (pre, body) = if let Some(r) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
129 (" • ".to_string(), r)
130 } else if let Some(r) = trimmed.strip_prefix(" - ").or_else(|| trimmed.strip_prefix(" * ")) {
131 (" ◦ ".to_string(), r)
132 } else if let Some(r) = trimmed.strip_prefix(" - ").or_else(|| trimmed.strip_prefix(" * ")) {
133 (" · ".to_string(), r)
134 } else {
135 (" ".to_string(), trimmed)
136 };
137 let mut spans = vec![Span::styled(pre, Style::default().fg(theme::PINE()))];
138 spans.extend(parse_inline(body));
139 Line::from(spans)
140}
141
142impl<'a> Widget for MessageList<'a> {
143 fn render(self, area: Rect, buf: &mut Buffer) {
144 let pad = if area.width >= 80 { 4 } else { 2 };
145 let area = Rect {
146 x: area.x + pad,
147 width: area.width.saturating_sub(pad * 2),
148 y: area.y + 1,
149 height: area.height.saturating_sub(1),
150 };
151
152 if self.state.messages.is_empty() {
153 draw_empty_state(area, buf);
154 return;
155 }
156
157 let mut lines: Vec<Line<'static>> = Vec::new();
158
159 for msg in &self.state.messages {
160 match msg.role.as_str() {
161 "user" => {
162 for (i, raw) in msg.content.lines().enumerate() {
163 let line = if i == 0 {
164 Line::from(vec![
165 Span::styled(" ❯ ", Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)),
166 Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::TEXT())),
167 ])
168 } else {
169 Line::from(Span::styled(format!(" {}", raw.trim_end()), Style::default().fg(theme::TEXT())))
170 };
171 lines.push(line);
172 }
173 }
174 "error" => {
175 for (i, raw) in msg.content.lines().enumerate() {
176 let line = if i == 0 {
177 Line::from(vec![
178 Span::styled(" ✗ ", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
179 Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::LOVE())),
180 ])
181 } else {
182 Line::from(Span::styled(format!(" {}", raw.trim_end()), Style::default().fg(theme::LOVE())))
183 };
184 lines.push(line);
185 }
186 }
187 "system" => {
188 for raw in msg.content.lines() {
189 lines.push(Line::from(Span::styled(
190 format!(" · {}", raw.trim_end()),
191 Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
192 )));
193 }
194 }
195 "done" => {
196 lines.push(Line::from(vec![
197 Span::styled(
198 " ✓ ",
199 Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD),
200 ),
201 Span::styled(
202 msg.content.clone(),
203 Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
204 ),
205 ]));
206 }
207 _ => {
208 if !msg.thinking.is_empty() && !msg.is_streaming {
209 let lc = msg.thinking.lines().count();
210 lines.push(Line::from(Span::styled(
211 format!(" ◈ thinking · {lc} lines"),
212 Style::default()
213 .fg(theme::MUTED())
214 .add_modifier(Modifier::ITALIC | Modifier::DIM),
215 )));
216 lines.push(Line::from(""));
217 }
218 let has_content = !msg.content.trim().is_empty();
219 let has_tools = !msg.tool_uses.is_empty();
220 let mut in_code = false;
221 let mut in_mermaid = false;
222 let mut prev_blank = false;
223 for raw in msg.content.lines() {
224 let trimmed_start = raw.trim_start();
225 let is_blank = raw.trim().is_empty();
226
227 if is_blank {
228 if !prev_blank { lines.push(Line::from("")); }
229 prev_blank = true;
230 continue;
231 }
232 prev_blank = false;
233
234 if trimmed_start.starts_with("```mermaid") {
235 in_mermaid = true; in_code = true;
236 lines.push(Line::from(Span::styled(" ╭─ mermaid ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD))));
237 } else if in_mermaid && trimmed_start.starts_with("```") {
238 in_mermaid = false; in_code = false;
239 lines.push(Line::from(Span::styled(" ╰────────── ", Style::default().fg(theme::IRIS()))));
240 } else if in_mermaid {
241 lines.push(Line::from(vec![
242 Span::styled(" │ ", Style::default().fg(theme::IRIS())),
243 Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::GOLD())),
244 ]));
245 } else if trimmed_start.starts_with("```") {
246 if in_code {
247 in_code = false;
248 lines.push(Line::from(Span::styled(" ╰──", Style::default().fg(theme::OVERLAY()))));
249 } else {
250 in_code = true;
251 let lang = trimmed_start.trim_start_matches('`').trim();
252 let label = if lang.is_empty() { " ╭─ code".to_string() } else { format!(" ╭─ {lang}") };
253 lines.push(Line::from(Span::styled(label, Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM))));
254 }
255 } else {
256 lines.push(render_md_line(raw, in_code));
257 }
258 }
259 let _ = in_code;
260 if has_content && has_tools {
261 lines.push(Line::from(""));
262 }
263 for tool in &msg.tool_uses {
264 let (dot, col) = match tool.status {
265 ToolUseStatus::Running => (
266 FRAMES[self.spinner_frame % FRAMES.len()].to_string(),
267 theme::GOLD(),
268 ),
269 ToolUseStatus::Completed => ("●".into(), theme::SUCCESS()),
270 ToolUseStatus::Error => ("●".into(), theme::ERROR()),
271 };
272
273 let pretty_name = pretty_tool_name(&tool.name);
274 let header_text = if tool.input_summary.is_empty() {
275 pretty_name.to_string()
276 } else {
277 format!("{pretty_name}({})", tool.input_summary)
278 };
279 lines.push(Line::from(vec![
280 Span::styled(
281 format!("{dot} "),
282 Style::default().fg(col).add_modifier(Modifier::BOLD),
283 ),
284 Span::styled(
285 header_text,
286 Style::default().fg(theme::TEXT()).add_modifier(Modifier::BOLD),
287 ),
288 ]));
289
290 if self.tool_details && !tool.diff.is_empty() {
291 let max_diff_show = 6usize;
293 let total_diff = tool.diff.len();
294 let show = total_diff.min(max_diff_show);
295 for (i, d) in tool.diff.iter().take(show).enumerate() {
296 let (sign, sign_fg, body_fg, body_bg) = match d.kind {
297 DiffLineKind::Added => (
298 "+",
299 theme::SUCCESS(),
300 theme::TEXT(),
301 Some(theme::HL_MED()),
302 ),
303 DiffLineKind::Removed => (
304 "-",
305 theme::ERROR(),
306 theme::TEXT(),
307 Some(theme::HL_MED()),
308 ),
309 DiffLineKind::Context => (
310 " ",
311 theme::TEXT_MUTED(),
312 theme::TEXT_MUTED(),
313 None,
314 ),
315 };
316 let prefix = if i == 0 { " ⎿ " } else { " " };
317 let sign_style = Style::default()
318 .fg(sign_fg)
319 .add_modifier(Modifier::BOLD);
320 let mut body_style = Style::default().fg(body_fg);
321 if let Some(bg) = body_bg {
322 body_style = body_style.bg(bg);
323 }
324 lines.push(Line::from(vec![
325 Span::styled(
326 prefix,
327 Style::default().fg(theme::OVERLAY()),
328 ),
329 Span::styled(sign.to_string(), sign_style),
330 Span::styled(format!(" {}", d.text), body_style),
331 ]));
332 }
333 if total_diff > show {
334 lines.push(Line::from(vec![
335 Span::styled(
336 " ",
337 Style::default().fg(theme::OVERLAY()),
338 ),
339 Span::styled(
340 format!("… +{} lines (ctrl+o to expand)", total_diff - show),
341 Style::default()
342 .fg(theme::TEXT_MUTED())
343 .add_modifier(Modifier::ITALIC),
344 ),
345 ]));
346 }
347 }
348
349 let suppress_body = matches!(tool.name.as_str(), "read");
352 let body_lines: &[String] = if !self.tool_details {
353 &[]
354 } else if !tool.diff.is_empty() || suppress_body {
355 &[]
356 } else if tool.output_excerpt.is_empty()
357 && !tool.output_preview.is_empty()
358 {
359 std::slice::from_ref(&tool.output_preview)
360 } else {
361 tool.output_excerpt.as_slice()
362 };
363
364 let max_body_show = 2usize;
365 let total_body = body_lines.len();
366 let show = total_body.min(max_body_show);
367 let body_width = (area.width as usize).saturating_sub(7).max(20);
368 for (i, body) in body_lines.iter().take(show).enumerate() {
369 let cleaned = clean_tool_body_line(&tool.name, body);
370 let truncated = truncate_to_width(&cleaned, body_width);
371 let prefix = if i == 0 { " ⎿ " } else { " " };
372 lines.push(Line::from(vec![
373 Span::styled(
374 prefix,
375 Style::default().fg(theme::OVERLAY()),
376 ),
377 Span::styled(
378 truncated,
379 Style::default().fg(theme::TEXT_MUTED()),
380 ),
381 ]));
382 }
383 if total_body > show {
384 lines.push(Line::from(vec![
385 Span::styled(
386 " ",
387 Style::default().fg(theme::OVERLAY()),
388 ),
389 Span::styled(
390 format!("… +{} lines (ctrl+o to expand)", total_body - show),
391 Style::default()
392 .fg(theme::TEXT_MUTED())
393 .add_modifier(Modifier::ITALIC),
394 ),
395 ]));
396 }
397 lines.push(Line::from(""));
399 }
400 }
401 }
402 lines.push(Line::from(""));
403 }
404
405 lines.push(Line::from(""));
406 lines.push(Line::from(""));
407
408 let width = area.width as usize;
409 let total_rows: usize = lines
410 .iter()
411 .map(|l| {
412 let w = l.width();
413 if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
414 })
415 .sum();
416
417 let visible = area.height as usize;
418 self.state.total_lines = total_rows;
419 let max_offset = total_rows.saturating_sub(visible);
420 let offset = if self.state.auto_scroll {
421 self.state.scroll_offset = max_offset;
422 max_offset
423 } else {
424 let o = self.state.scroll_offset.min(max_offset);
425 self.state.scroll_offset = o;
426 o
427 };
428
429 Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
430 }
431}
432
433fn truncate_to_width(s: &str, max: usize) -> String {
434 use unicode_width::UnicodeWidthChar;
435 let mut out = String::with_capacity(s.len());
436 let mut width = 0usize;
437 let mut truncated = false;
438 for c in s.chars() {
439 let w = c.width().unwrap_or(0);
440 if width + w > max.saturating_sub(1) {
441 truncated = true;
442 break;
443 }
444 width += w;
445 out.push(c);
446 }
447 if truncated {
448 out.push('…');
449 }
450 out
451}
452
453fn pretty_tool_name(name: &str) -> String {
455 match name {
456 "bash" => "Bash".into(),
457 "read" => "Read".into(),
458 "file_write" => "Write".into(),
459 "file_edit" => "Edit".into(),
460 "glob" => "Glob".into(),
461 "grep" => "Grep".into(),
462 "web_fetch" => "WebFetch".into(),
463 "web_search" => "WebSearch".into(),
464 "todo_write" => "TodoWrite".into(),
465 "todo_read" => "TodoRead".into(),
466 "ask_user_question" => "AskUser".into(),
467 "agent" => "Agent".into(),
468 "explore" => "Explore".into(),
469 _ => {
470 name.split('_')
472 .map(|seg| {
473 let mut chars = seg.chars();
474 match chars.next() {
475 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
476 None => String::new(),
477 }
478 })
479 .collect()
480 }
481 }
482}
483
484fn clean_tool_body_line(tool: &str, raw: &str) -> String {
486 let line = raw.trim_end();
487 if tool == "read" {
488 if let Some(rest) = strip_read_prefix(line) {
491 return rest.to_string();
492 }
493 }
494 line.to_string()
495}
496
497fn strip_read_prefix(s: &str) -> Option<&str> {
498 let bytes = s.as_bytes();
499 let mut i = 0;
500 while i < bytes.len() && bytes[i] == b' ' { i += 1; }
501 let digits_start = i;
502 while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
503 if i == digits_start { return None; }
504 if i >= bytes.len() { return None; }
505 if bytes[i] == b'\t' { return Some(&s[i + 1..]); }
506 if bytes[i] == b' ' {
507 let mut j = i;
508 while j < bytes.len() && bytes[j] == b' ' { j += 1; }
509 if j - i >= 2 {
510 return Some(&s[j..]);
511 }
512 }
513 None
514}
515
516const LOGO_ART: &[&str] = &[
517 r" ____ _____ __ ___ __ __ ",
518 r" / ___|_ _\ \ / / \ | \ \ / /",
519 r" \___ \ | | \ V /| \| |\ V / ",
520 r" ___) || | | | | |\ | | | ",
521 r" |____/ |_| |_| |_| \_| |_| ",
522];
523const LOGO_SUBTITLE: &str = "c o d e";
524
525const HINTS: &[(&str, &str)] = &[
526 ("^P", "command palette"),
527 ("^S", "session list"),
528 ("^M", "switch model"),
529 ("/help", "show help"),
530];
531
532fn draw_empty_state(area: Rect, buf: &mut Buffer) {
533 let mut lines: Vec<Line<'static>> = Vec::new();
534
535 let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
536 let pad_to = |s: &str, w: usize| -> String {
537 let n = s.chars().count();
538 if n >= w { s.to_string() } else {
539 let extra = w - n;
540 let left = extra / 2;
541 let right = extra - left;
542 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
543 }
544 };
545
546 let top_pad = area.height.saturating_sub(16) / 3;
547 for _ in 0..top_pad { lines.push(Line::from("")); }
548
549 for art in LOGO_ART {
550 lines.push(Line::from(Span::styled(
551 pad_to(art, logo_w),
552 Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
553 )));
554 }
555 lines.push(Line::from(Span::styled(
556 pad_to(LOGO_SUBTITLE, logo_w),
557 Style::default().fg(theme::ACCENT()),
558 )));
559 lines.push(Line::from(""));
560 lines.push(Line::from(Span::styled(
561 format!("v{}", env!("CARGO_PKG_VERSION")),
562 Style::default().fg(theme::TEXT_MUTED()),
563 )));
564 lines.push(Line::from(""));
565 lines.push(Line::from(Span::styled(
566 "────────────────────".to_string(),
567 Style::default().fg(theme::BORDER()),
568 )));
569 lines.push(Line::from(""));
570
571 let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
572 let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
573 for (k, l) in HINTS {
574 let key = format!("{:>width$}", k, width = key_w);
575 let label = format!("{:<width$}", l, width = label_w);
576 lines.push(Line::from(vec![
577 Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
578 Span::raw(" "),
579 Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
580 ]));
581 }
582 lines.push(Line::from(""));
583 lines.push(Line::from(Span::styled(
584 "type a message to begin…".to_string(),
585 Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
586 )));
587
588 Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
589}