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