Skip to main content

stynx_code_tui/widgets/
message_list.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Paragraph, Widget, Wrap},
7};
8
9use ratatui::layout::Alignment;
10
11use crate::state::ConversationState;
12use crate::theme;
13use super::markdown::{render_md_line_wrapped, is_table_line, render_table_block};
14
15pub struct MessageList<'a> {
16    pub state: &'a mut ConversationState,
17    pub spinner_frame: usize,
18    pub tool_details: bool,
19    pub thinking_active: 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, thinking_active: false }
25    }
26
27    pub fn with_tool_details(mut self, on: bool) -> Self {
28        self.tool_details = on;
29        self
30    }
31
32    pub fn with_thinking_active(mut self, on: bool) -> Self {
33        self.thinking_active = on;
34        self
35    }
36}
37
38impl<'a> Widget for MessageList<'a> {
39    fn render(self, area: Rect, buf: &mut Buffer) {
40        // Align the role accent bar (▌) flush to the left edge (main.x), matching
41        // the thinking panel's bar and the tools border. Keep a 1-col right margin.
42        let area = Rect {
43            x: area.x,
44            width: area.width.saturating_sub(1),
45            y: area.y + 1,
46            height: area.height.saturating_sub(1),
47        };
48
49        if self.state.messages.is_empty() {
50            draw_empty_state(area, buf);
51            return;
52        }
53
54        let mut lines: Vec<Line<'static>> = Vec::new();
55
56        let bar = "▌";
57        let body_indent = "  ";
58        let thinking_active = self.thinking_active;
59        let spin = super::spinner::FRAMES[self.spinner_frame % super::spinner::FRAMES.len()];
60
61        for msg in &self.state.messages {
62            match msg.role.as_str() {
63                "user" => {
64                    let start = lines.len();
65                    for raw in msg.content.lines() {
66                        lines.push(Line::from(Span::styled(
67                            format!("{body_indent}{}", raw.trim_end()),
68                            Style::default().fg(theme::TEXT()),
69                        )));
70                    }
71                    stamp_role_bar(&mut lines, start, theme::FOAM());
72                }
73                "error" => {
74                    lines.push(Line::from(vec![
75                        Span::styled(bar, Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
76                        Span::styled(" Error", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
77                    ]));
78                    for raw in msg.content.lines() {
79                        lines.push(Line::from(Span::styled(
80                            format!("{body_indent}{}", raw.trim_end()),
81                            Style::default().fg(theme::LOVE()),
82                        )));
83                    }
84                }
85                "system" => {
86                    for raw in msg.content.lines() {
87                        lines.push(Line::from(vec![
88                            Span::styled(bar, Style::default().fg(theme::SUBTLE())),
89                            Span::styled(
90                                format!(" {}", raw.trim_end()),
91                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
92                            ),
93                        ]));
94                    }
95                }
96                "done" => {
97                    let parts: Vec<&str> = msg.content.split(", ").collect();
98                    for (i, part) in parts.iter().enumerate() {
99                        let prefix = if i == 0 { "  ✓ " } else { "    " };
100                        let prefix_style = if i == 0 {
101                            Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD)
102                        } else {
103                            Style::default()
104                        };
105                        lines.push(Line::from(vec![
106                            Span::styled(prefix, prefix_style),
107                            Span::styled(
108                                part.to_string(),
109                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
110                            ),
111                        ]));
112                    }
113                }
114                _ => {
115                    let asst_start = lines.len();
116                    if msg.is_streaming && thinking_active && msg.content.trim().is_empty() {
117                        lines.push(Line::from(vec![
118                            Span::styled(
119                                format!("  {spin} "),
120                                Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD),
121                            ),
122                            Span::styled(
123                                "thinking…",
124                                Style::default()
125                                    .fg(theme::MUTED())
126                                    .add_modifier(Modifier::ITALIC),
127                            ),
128                        ]));
129                        lines.push(Line::from(""));
130                    }
131                    if !msg.thinking.is_empty() && !msg.is_streaming {
132                        let lc = msg.thinking.lines().count();
133                        lines.push(Line::from(Span::styled(
134                            format!("  ◈ thinking · {lc} lines"),
135                            Style::default()
136                                .fg(theme::MUTED())
137                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
138                        )));
139                        lines.push(Line::from(""));
140                    }
141                    let content_lines: Vec<&str> = msg.content.lines().collect();
142                    let mut in_code = false;
143                    let mut in_mermaid = false;
144                    let mut prev_blank = false;
145                    let mut i = 0;
146                    while i < content_lines.len() {
147                        let raw = content_lines[i];
148                        let trimmed_start = raw.trim_start();
149                        let is_blank = raw.trim().is_empty();
150
151                        if is_blank {
152                            if !prev_blank { lines.push(Line::from("")); }
153                            prev_blank = true;
154                            i += 1;
155                            continue;
156                        }
157                        prev_blank = false;
158
159                        if trimmed_start.starts_with("```mermaid") {
160                            in_mermaid = true; in_code = true;
161                            lines.push(Line::from(Span::styled("  ╭─ mermaid ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD))));
162                        } else if in_mermaid && trimmed_start.starts_with("```") {
163                            in_mermaid = false; in_code = false;
164                            lines.push(Line::from(Span::styled("  ╰────────── ", Style::default().fg(theme::IRIS()))));
165                        } else if in_mermaid {
166                            lines.push(Line::from(vec![
167                                Span::styled("  │ ", Style::default().fg(theme::IRIS())),
168                                Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::GOLD())),
169                            ]));
170                        } else if trimmed_start.starts_with("```") {
171                            if in_code {
172                                in_code = false;
173                                lines.push(Line::from(Span::styled("  ╰──", Style::default().fg(theme::OVERLAY()))));
174                            } else {
175                                in_code = true;
176                                let lang = trimmed_start.trim_start_matches('`').trim();
177                                let label = if lang.is_empty() { "  ╭─ code".to_string() } else { format!("  ╭─ {lang}") };
178                                lines.push(Line::from(Span::styled(label, Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM))));
179                            }
180                        } else if !in_code && is_table_line(raw) {
181                            // Gather the whole contiguous table block and render it
182                            // aligned (columns padded to a common width).
183                            let start = i;
184                            while i < content_lines.len() && is_table_line(content_lines[i]) {
185                                i += 1;
186                            }
187                            lines.extend(render_table_block(&content_lines[start..i], area.width as usize));
188                            continue;
189                        } else {
190                            lines.extend(render_md_line_wrapped(raw, in_code, (area.width as usize).saturating_sub(1)));
191                        }
192                        i += 1;
193                    }
194                    let _ = in_code;
195                    stamp_role_bar(&mut lines, asst_start, theme::IRIS());
196                }
197            }
198            lines.push(Line::from(""));
199        }
200
201        lines.push(Line::from(""));
202        lines.push(Line::from(""));
203
204        let width = area.width as usize;
205        let total_rows: usize = lines
206            .iter()
207            .map(|l| {
208                let w = l.width();
209                if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
210            })
211            .sum();
212
213        let visible = area.height as usize;
214        self.state.total_lines = total_rows;
215
216        // When the conversation doesn't fill the viewport, anchor it to the
217        // bottom (growing upward from the input) instead of leaving a large
218        // empty gap below the messages.
219        if total_rows < visible {
220            let pad = visible - total_rows;
221            let mut anchored: Vec<Line<'static>> = Vec::with_capacity(pad + lines.len());
222            anchored.resize(pad, Line::from(""));
223            anchored.extend(lines);
224            self.state.scroll_offset = 0;
225            Paragraph::new(anchored).wrap(Wrap { trim: false }).render(area, buf);
226            return;
227        }
228
229        let max_offset = total_rows.saturating_sub(visible);
230        let offset = if self.state.auto_scroll {
231            self.state.scroll_offset = max_offset;
232            max_offset
233        } else {
234            let o = self.state.scroll_offset.min(max_offset);
235            self.state.scroll_offset = o;
236            o
237        };
238
239        Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
240    }
241}
242
243/// Stamp a colored role bar (▌) onto a message's first body line, replacing its
244/// leading indent. The bar's color is the sole role indicator (foam = user,
245/// iris = assistant) — no text label — so messages stay differentiable but clean.
246fn stamp_role_bar(lines: &mut [Line<'static>], start: usize, color: Color) {
247    let line = match lines.get_mut(start) {
248        Some(l) => l,
249        None => return,
250    };
251    if let Some(first) = line.spans.first_mut() {
252        let trimmed = first
253            .content
254            .strip_prefix("  ")
255            .or_else(|| first.content.strip_prefix(' '))
256            .map(|s| s.to_string());
257        if let Some(t) = trimmed {
258            first.content = t.into();
259        }
260    }
261    line.spans.insert(
262        0,
263        Span::styled("▌ ", Style::default().fg(color).add_modifier(Modifier::BOLD)),
264    );
265}
266
267const LOGO_ART: &[&str] = &[
268    r" ____   _____ __   __ _   _ __  __",
269    r"/ ___| |_   _|\ \ / /| \ | |\ \/ /",
270    r"\___ \   | |   \ V / |  \| | \  / ",
271    r" ___) |  | |    | |  | |\  | /  \ ",
272    r"|____/   |_|    |_|  |_| \_|/_/\_\",
273];
274const LOGO_SUBTITLE: &str = "c   o   d   e";
275
276const HINTS: &[(&str, &str)] = &[
277    ("^P",    "command palette"),
278    ("^T",    "focus tools"),
279    ("^M",    "switch model"),
280    ("/help", "show help"),
281];
282
283fn draw_empty_state(area: Rect, buf: &mut Buffer) {
284    let mut lines: Vec<Line<'static>> = Vec::new();
285
286    let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
287    let pad_to = |s: &str, w: usize| -> String {
288        let n = s.chars().count();
289        if n >= w { s.to_string() } else {
290            let extra = w - n;
291            let left = extra / 2;
292            let right = extra - left;
293            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
294        }
295    };
296
297    let top_pad = area.height.saturating_sub(16) / 3;
298    for _ in 0..top_pad { lines.push(Line::from("")); }
299
300    for art in LOGO_ART {
301        lines.push(Line::from(Span::styled(
302            pad_to(art, logo_w),
303            Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
304        )));
305    }
306    lines.push(Line::from(Span::styled(
307        pad_to(LOGO_SUBTITLE, logo_w),
308        Style::default().fg(theme::ACCENT()),
309    )));
310    lines.push(Line::from(""));
311    lines.push(Line::from(Span::styled(
312        format!("v{}", env!("CARGO_PKG_VERSION")),
313        Style::default().fg(theme::TEXT_MUTED()),
314    )));
315    lines.push(Line::from(""));
316    lines.push(Line::from(Span::styled(
317        "────────────────────".to_string(),
318        Style::default().fg(theme::BORDER()),
319    )));
320    lines.push(Line::from(""));
321
322    let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
323    let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
324    for (k, l) in HINTS {
325        let key = format!("{:>width$}", k, width = key_w);
326        let label = format!("{:<width$}", l, width = label_w);
327        lines.push(Line::from(vec![
328            Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
329            Span::raw("   "),
330            Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
331        ]));
332    }
333    lines.push(Line::from(""));
334    lines.push(Line::from(Span::styled(
335        "type a message to begin…".to_string(),
336        Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
337    )));
338
339    Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
340}