Skip to main content

stynx_code_tui/widgets/
message_list.rs

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}