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 lines.push(Line::from(""));
288 }
289 }
290 }
291 lines.push(Line::from(""));
292 }
293
294 lines.push(Line::from(""));
295 lines.push(Line::from(""));
296
297 let width = area.width as usize;
298 let total_rows: usize = lines
299 .iter()
300 .map(|l| {
301 let w = l.width();
302 if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
303 })
304 .sum();
305
306 let visible = area.height as usize;
307 self.state.total_lines = total_rows;
308 let max_offset = total_rows.saturating_sub(visible);
309 let offset = if self.state.auto_scroll {
310 self.state.scroll_offset = max_offset;
311 max_offset
312 } else {
313 let o = self.state.scroll_offset.min(max_offset);
314 self.state.scroll_offset = o;
315 o
316 };
317
318 Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
319 }
320}
321
322fn truncate_to_width(s: &str, max: usize) -> String {
323 use unicode_width::UnicodeWidthChar;
324 let mut out = String::with_capacity(s.len());
325 let mut width = 0usize;
326 let mut truncated = false;
327 for c in s.chars() {
328 let w = c.width().unwrap_or(0);
329 if width + w > max.saturating_sub(1) {
330 truncated = true;
331 break;
332 }
333 width += w;
334 out.push(c);
335 }
336 if truncated {
337 out.push('…');
338 }
339 out
340}
341
342fn pretty_tool_name(name: &str) -> String {
343 match name {
344 "bash" => "Bash".into(),
345 "read" => "Read".into(),
346 "file_write" => "Write".into(),
347 "file_edit" => "Edit".into(),
348 "glob" => "Glob".into(),
349 "grep" => "Grep".into(),
350 "web_fetch" => "WebFetch".into(),
351 "web_search" => "WebSearch".into(),
352 "todo_write" => "TodoWrite".into(),
353 "todo_read" => "TodoRead".into(),
354 "ask_user_question" => "AskUser".into(),
355 "agent" => "Agent".into(),
356 "explore" => "Explore".into(),
357 _ => {
358
359 name.split('_')
360 .map(|seg| {
361 let mut chars = seg.chars();
362 match chars.next() {
363 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
364 None => String::new(),
365 }
366 })
367 .collect()
368 }
369 }
370}
371
372fn clean_tool_body_line(tool: &str, raw: &str) -> String {
373 let line = raw.trim_end();
374 if tool == "read" {
375
376 if let Some(rest) = strip_read_prefix(line) {
377 return rest.to_string();
378 }
379 }
380 line.to_string()
381}
382
383fn strip_read_prefix(s: &str) -> Option<&str> {
384 let bytes = s.as_bytes();
385 let mut i = 0;
386 while i < bytes.len() && bytes[i] == b' ' { i += 1; }
387 let digits_start = i;
388 while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
389 if i == digits_start { return None; }
390 if i >= bytes.len() { return None; }
391 if bytes[i] == b'\t' { return Some(&s[i + 1..]); }
392 if bytes[i] == b' ' {
393 let mut j = i;
394 while j < bytes.len() && bytes[j] == b' ' { j += 1; }
395 if j - i >= 2 {
396 return Some(&s[j..]);
397 }
398 }
399 None
400}
401
402const LOGO_ART: &[&str] = &[
403 r" ____ _____ __ __ _ _ __ __",
404 r"/ ___| |_ _|\ \ / /| \ | |\ \/ /",
405 r"\___ \ | | \ V / | \| | \ / ",
406 r" ___) | | | | | | |\ | / \ ",
407 r"|____/ |_| |_| |_| \_|/_/\_\",
408];
409const LOGO_SUBTITLE: &str = "c o d e";
410
411const HINTS: &[(&str, &str)] = &[
412 ("^P", "command palette"),
413 ("^S", "session list"),
414 ("^M", "switch model"),
415 ("/help", "show help"),
416];
417
418fn draw_empty_state(area: Rect, buf: &mut Buffer) {
419 let mut lines: Vec<Line<'static>> = Vec::new();
420
421 let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
422 let pad_to = |s: &str, w: usize| -> String {
423 let n = s.chars().count();
424 if n >= w { s.to_string() } else {
425 let extra = w - n;
426 let left = extra / 2;
427 let right = extra - left;
428 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
429 }
430 };
431
432 let top_pad = area.height.saturating_sub(16) / 3;
433 for _ in 0..top_pad { lines.push(Line::from("")); }
434
435 for art in LOGO_ART {
436 lines.push(Line::from(Span::styled(
437 pad_to(art, logo_w),
438 Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
439 )));
440 }
441 lines.push(Line::from(Span::styled(
442 pad_to(LOGO_SUBTITLE, logo_w),
443 Style::default().fg(theme::ACCENT()),
444 )));
445 lines.push(Line::from(""));
446 lines.push(Line::from(Span::styled(
447 format!("v{}", env!("CARGO_PKG_VERSION")),
448 Style::default().fg(theme::TEXT_MUTED()),
449 )));
450 lines.push(Line::from(""));
451 lines.push(Line::from(Span::styled(
452 "────────────────────".to_string(),
453 Style::default().fg(theme::BORDER()),
454 )));
455 lines.push(Line::from(""));
456
457 let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
458 let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
459 for (k, l) in HINTS {
460 let key = format!("{:>width$}", k, width = key_w);
461 let label = format!("{:<width$}", l, width = label_w);
462 lines.push(Line::from(vec![
463 Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
464 Span::raw(" "),
465 Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
466 ]));
467 }
468 lines.push(Line::from(""));
469 lines.push(Line::from(Span::styled(
470 "type a message to begin…".to_string(),
471 Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
472 )));
473
474 Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
475}