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;
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                _ => {
196                    if !msg.thinking.is_empty() && !msg.is_streaming {
197                        let lc = msg.thinking.lines().count();
198                        lines.push(Line::from(Span::styled(
199                            format!("  ◈ thinking · {lc} lines"),
200                            Style::default()
201                                .fg(theme::MUTED())
202                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
203                        )));
204                    }
205                    let mut in_code = false;
206                    let mut in_mermaid = false;
207                    let mut prev_blank = false;
208                    for raw in msg.content.lines() {
209                        let trimmed_start = raw.trim_start();
210                        let is_blank = raw.trim().is_empty();
211
212                        if is_blank {
213                            if !prev_blank { lines.push(Line::from("")); }
214                            prev_blank = true;
215                            continue;
216                        }
217                        prev_blank = false;
218
219                        if trimmed_start.starts_with("```mermaid") {
220                            in_mermaid = true; in_code = true;
221                            lines.push(Line::from(Span::styled("  ╭─ mermaid ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD))));
222                        } else if in_mermaid && trimmed_start.starts_with("```") {
223                            in_mermaid = false; in_code = false;
224                            lines.push(Line::from(Span::styled("  ╰────────── ", Style::default().fg(theme::IRIS()))));
225                        } else if in_mermaid {
226                            lines.push(Line::from(vec![
227                                Span::styled("  │ ", Style::default().fg(theme::IRIS())),
228                                Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::GOLD())),
229                            ]));
230                        } else if trimmed_start.starts_with("```") {
231                            if in_code {
232                                in_code = false;
233                                lines.push(Line::from(Span::styled("  ╰──", Style::default().fg(theme::OVERLAY()))));
234                            } else {
235                                in_code = true;
236                                let lang = trimmed_start.trim_start_matches('`').trim();
237                                let label = if lang.is_empty() { "  ╭─ code".to_string() } else { format!("  ╭─ {lang}") };
238                                lines.push(Line::from(Span::styled(label, Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM))));
239                            }
240                        } else {
241                            lines.push(render_md_line(raw, in_code));
242                        }
243                    }
244                    let _ = in_code;
245                    for tool in &msg.tool_uses {
246                        let (dot, col) = match tool.status {
247                            ToolUseStatus::Running => (
248                                FRAMES[self.spinner_frame % FRAMES.len()].to_string(),
249                                theme::GOLD(),
250                            ),
251                            ToolUseStatus::Completed => ("●".into(), theme::SUCCESS()),
252                            ToolUseStatus::Error => ("●".into(), theme::ERROR()),
253                        };
254
255                        let pretty_name = pretty_tool_name(&tool.name);
256                        let header_text = if tool.input_summary.is_empty() {
257                            pretty_name.to_string()
258                        } else {
259                            format!("{pretty_name}({})", tool.input_summary)
260                        };
261                        lines.push(Line::from(vec![
262                            Span::styled(
263                                format!("{dot} "),
264                                Style::default().fg(col).add_modifier(Modifier::BOLD),
265                            ),
266                            Span::styled(
267                                header_text,
268                                Style::default().fg(theme::TEXT()).add_modifier(Modifier::BOLD),
269                            ),
270                        ]));
271
272                        if self.tool_details && !tool.diff.is_empty() {
273                            // Diff rendering with corner connector on first line.
274                            let max_diff_show = 6usize;
275                            let total_diff = tool.diff.len();
276                            let show = total_diff.min(max_diff_show);
277                            for (i, d) in tool.diff.iter().take(show).enumerate() {
278                                let (sign, sign_fg, body_fg, body_bg) = match d.kind {
279                                    DiffLineKind::Added => (
280                                        "+",
281                                        theme::SUCCESS(),
282                                        theme::TEXT(),
283                                        Some(theme::HL_MED()),
284                                    ),
285                                    DiffLineKind::Removed => (
286                                        "-",
287                                        theme::ERROR(),
288                                        theme::TEXT(),
289                                        Some(theme::HL_MED()),
290                                    ),
291                                    DiffLineKind::Context => (
292                                        " ",
293                                        theme::TEXT_MUTED(),
294                                        theme::TEXT_MUTED(),
295                                        None,
296                                    ),
297                                };
298                                let prefix = if i == 0 { "  ⎿  " } else { "     " };
299                                let sign_style = Style::default()
300                                    .fg(sign_fg)
301                                    .add_modifier(Modifier::BOLD);
302                                let mut body_style = Style::default().fg(body_fg);
303                                if let Some(bg) = body_bg {
304                                    body_style = body_style.bg(bg);
305                                }
306                                lines.push(Line::from(vec![
307                                    Span::styled(
308                                        prefix,
309                                        Style::default().fg(theme::OVERLAY()),
310                                    ),
311                                    Span::styled(sign.to_string(), sign_style),
312                                    Span::styled(format!(" {}", d.text), body_style),
313                                ]));
314                            }
315                            if total_diff > show {
316                                lines.push(Line::from(vec![
317                                    Span::styled(
318                                        "     ",
319                                        Style::default().fg(theme::OVERLAY()),
320                                    ),
321                                    Span::styled(
322                                        format!("… +{} lines (ctrl+o to expand)", total_diff - show),
323                                        Style::default()
324                                            .fg(theme::TEXT_MUTED())
325                                            .add_modifier(Modifier::ITALIC),
326                                    ),
327                                ]));
328                            }
329                        }
330
331                        // `read` content excerpt is noise — the user already
332                        // sees the file. Header line-count badge is enough.
333                        let suppress_body = matches!(tool.name.as_str(), "read");
334                        let body_lines: &[String] = if !self.tool_details {
335                            &[]
336                        } else if !tool.diff.is_empty() || suppress_body {
337                            &[]
338                        } else if tool.output_excerpt.is_empty()
339                            && !tool.output_preview.is_empty()
340                        {
341                            std::slice::from_ref(&tool.output_preview)
342                        } else {
343                            tool.output_excerpt.as_slice()
344                        };
345
346                        let max_body_show = 2usize;
347                        let total_body = body_lines.len();
348                        let show = total_body.min(max_body_show);
349                        let body_width = (area.width as usize).saturating_sub(7).max(20);
350                        for (i, body) in body_lines.iter().take(show).enumerate() {
351                            let cleaned = clean_tool_body_line(&tool.name, body);
352                            let truncated = truncate_to_width(&cleaned, body_width);
353                            let prefix = if i == 0 { "  ⎿  " } else { "     " };
354                            lines.push(Line::from(vec![
355                                Span::styled(
356                                    prefix,
357                                    Style::default().fg(theme::OVERLAY()),
358                                ),
359                                Span::styled(
360                                    truncated,
361                                    Style::default().fg(theme::TEXT_MUTED()),
362                                ),
363                            ]));
364                        }
365                        if total_body > show {
366                            lines.push(Line::from(vec![
367                                Span::styled(
368                                    "     ",
369                                    Style::default().fg(theme::OVERLAY()),
370                                ),
371                                Span::styled(
372                                    format!("… +{} lines (ctrl+o to expand)", total_body - show),
373                                    Style::default()
374                                        .fg(theme::TEXT_MUTED())
375                                        .add_modifier(Modifier::ITALIC),
376                                ),
377                            ]));
378                        }
379                        // Breathing room between consecutive tool blocks.
380                        lines.push(Line::from(""));
381                    }
382                }
383            }
384            lines.push(Line::from(""));
385        }
386
387        lines.push(Line::from(""));
388        lines.push(Line::from(""));
389
390        let width = area.width as usize;
391        let total_rows: usize = lines
392            .iter()
393            .map(|l| {
394                let w = l.width();
395                if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
396            })
397            .sum();
398
399        let visible = area.height as usize;
400        self.state.total_lines = total_rows;
401        let max_offset = total_rows.saturating_sub(visible);
402        let offset = if self.state.auto_scroll {
403            self.state.scroll_offset = max_offset;
404            max_offset
405        } else {
406            let o = self.state.scroll_offset.min(max_offset);
407            self.state.scroll_offset = o;
408            o
409        };
410
411        Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
412    }
413}
414
415fn truncate_to_width(s: &str, max: usize) -> String {
416    use unicode_width::UnicodeWidthChar;
417    let mut out = String::with_capacity(s.len());
418    let mut width = 0usize;
419    let mut truncated = false;
420    for c in s.chars() {
421        let w = c.width().unwrap_or(0);
422        if width + w > max.saturating_sub(1) {
423            truncated = true;
424            break;
425        }
426        width += w;
427        out.push(c);
428    }
429    if truncated {
430        out.push('…');
431    }
432    out
433}
434
435/// Map snake_case tool names to a display form like "Bash", "FileWrite".
436fn pretty_tool_name(name: &str) -> String {
437    match name {
438        "bash" => "Bash".into(),
439        "read" => "Read".into(),
440        "file_write" => "Write".into(),
441        "file_edit" => "Edit".into(),
442        "glob" => "Glob".into(),
443        "grep" => "Grep".into(),
444        "web_fetch" => "WebFetch".into(),
445        "web_search" => "WebSearch".into(),
446        "todo_write" => "TodoWrite".into(),
447        "todo_read" => "TodoRead".into(),
448        "ask_user_question" => "AskUser".into(),
449        "agent" => "Agent".into(),
450        "explore" => "Explore".into(),
451        _ => {
452            // Default: snake_case → PascalCase
453            name.split('_')
454                .map(|seg| {
455                    let mut chars = seg.chars();
456                    match chars.next() {
457                        Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
458                        None => String::new(),
459                    }
460                })
461                .collect()
462        }
463    }
464}
465
466/// Per-tool cleanup of body excerpt lines before they hit the renderer.
467fn clean_tool_body_line(tool: &str, raw: &str) -> String {
468    let line = raw.trim_end();
469    if tool == "read" {
470        // The read tool emits "  <line>\t<content>". Strip the prefix so the
471        // body reads as code.
472        if let Some(rest) = strip_read_prefix(line) {
473            return rest.to_string();
474        }
475    }
476    line.to_string()
477}
478
479fn strip_read_prefix(s: &str) -> Option<&str> {
480    let bytes = s.as_bytes();
481    let mut i = 0;
482    while i < bytes.len() && bytes[i] == b' ' { i += 1; }
483    let digits_start = i;
484    while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
485    if i == digits_start { return None; }
486    if i >= bytes.len() { return None; }
487    if bytes[i] == b'\t' { return Some(&s[i + 1..]); }
488    if bytes[i] == b' ' {
489        let mut j = i;
490        while j < bytes.len() && bytes[j] == b' ' { j += 1; }
491        if j - i >= 2 {
492            return Some(&s[j..]);
493        }
494    }
495    None
496}
497
498const LOGO_ART: &[&str] = &[
499    r"  ____ _____ __   ___   __ __ ",
500    r" / ___|_   _\ \ / / \ | \ \ / /",
501    r" \___ \ | |  \ V /|  \| |\ V / ",
502    r"  ___) || |   | | | |\  | | |  ",
503    r" |____/ |_|   |_| |_| \_| |_|  ",
504];
505const LOGO_SUBTITLE: &str = "c   o   d   e";
506
507const HINTS: &[(&str, &str)] = &[
508    ("^P",    "command palette"),
509    ("^S",    "session list"),
510    ("^M",    "switch model"),
511    ("/help", "show help"),
512];
513
514fn draw_empty_state(area: Rect, buf: &mut Buffer) {
515    let mut lines: Vec<Line<'static>> = Vec::new();
516
517    let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
518    let pad_to = |s: &str, w: usize| -> String {
519        let n = s.chars().count();
520        if n >= w { s.to_string() } else {
521            let extra = w - n;
522            let left = extra / 2;
523            let right = extra - left;
524            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
525        }
526    };
527
528    let top_pad = area.height.saturating_sub(16) / 3;
529    for _ in 0..top_pad { lines.push(Line::from("")); }
530
531    for art in LOGO_ART {
532        lines.push(Line::from(Span::styled(
533            pad_to(art, logo_w),
534            Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
535        )));
536    }
537    lines.push(Line::from(Span::styled(
538        pad_to(LOGO_SUBTITLE, logo_w),
539        Style::default().fg(theme::ACCENT()),
540    )));
541    lines.push(Line::from(""));
542    lines.push(Line::from(Span::styled(
543        format!("v{}", env!("CARGO_PKG_VERSION")),
544        Style::default().fg(theme::TEXT_MUTED()),
545    )));
546    lines.push(Line::from(""));
547    lines.push(Line::from(Span::styled(
548        "────────────────────".to_string(),
549        Style::default().fg(theme::BORDER()),
550    )));
551    lines.push(Line::from(""));
552
553    let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
554    let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
555    for (k, l) in HINTS {
556        let key = format!("{:>width$}", k, width = key_w);
557        let label = format!("{:<width$}", l, width = label_w);
558        lines.push(Line::from(vec![
559            Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
560            Span::raw("   "),
561            Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
562        ]));
563    }
564    lines.push(Line::from(""));
565    lines.push(Line::from(Span::styled(
566        "type a message to begin…".to_string(),
567        Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
568    )));
569
570    Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
571}