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;
12use crate::theme;
13use super::markdown::{render_md_line, 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                    lines.push(Line::from(vec![
65                        Span::styled(bar, Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)),
66                        Span::styled(" You", Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)),
67                    ]));
68                    for raw in msg.content.lines() {
69                        lines.push(Line::from(Span::styled(
70                            format!("{body_indent}{}", raw.trim_end()),
71                            Style::default().fg(theme::TEXT()),
72                        )));
73                    }
74                }
75                "error" => {
76                    lines.push(Line::from(vec![
77                        Span::styled(bar, Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
78                        Span::styled(" Error", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
79                    ]));
80                    for raw in msg.content.lines() {
81                        lines.push(Line::from(Span::styled(
82                            format!("{body_indent}{}", raw.trim_end()),
83                            Style::default().fg(theme::LOVE()),
84                        )));
85                    }
86                }
87                "system" => {
88                    for raw in msg.content.lines() {
89                        lines.push(Line::from(vec![
90                            Span::styled(bar, Style::default().fg(theme::SUBTLE())),
91                            Span::styled(
92                                format!(" {}", raw.trim_end()),
93                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
94                            ),
95                        ]));
96                    }
97                }
98                "done" => {
99                    let parts: Vec<&str> = msg.content.split(", ").collect();
100                    for (i, part) in parts.iter().enumerate() {
101                        let prefix = if i == 0 { "  ✓ " } else { "    " };
102                        let prefix_style = if i == 0 {
103                            Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD)
104                        } else {
105                            Style::default()
106                        };
107                        lines.push(Line::from(vec![
108                            Span::styled(prefix, prefix_style),
109                            Span::styled(
110                                part.to_string(),
111                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
112                            ),
113                        ]));
114                    }
115                }
116                _ => {
117                    lines.push(Line::from(vec![
118                        Span::styled(bar, Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD)),
119                        Span::styled(" Stynx", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD)),
120                    ]));
121                    if msg.is_streaming && thinking_active && msg.content.trim().is_empty() {
122                        lines.push(Line::from(vec![
123                            Span::styled(
124                                format!("  {spin} "),
125                                Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD),
126                            ),
127                            Span::styled(
128                                "thinking…",
129                                Style::default()
130                                    .fg(theme::MUTED())
131                                    .add_modifier(Modifier::ITALIC),
132                            ),
133                        ]));
134                        lines.push(Line::from(""));
135                    }
136                    if !msg.thinking.is_empty() && !msg.is_streaming {
137                        let lc = msg.thinking.lines().count();
138                        lines.push(Line::from(Span::styled(
139                            format!("  ◈ thinking · {lc} lines"),
140                            Style::default()
141                                .fg(theme::MUTED())
142                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
143                        )));
144                        lines.push(Line::from(""));
145                    }
146                    let content_lines: Vec<&str> = msg.content.lines().collect();
147                    let mut in_code = false;
148                    let mut in_mermaid = false;
149                    let mut prev_blank = false;
150                    let mut i = 0;
151                    while i < content_lines.len() {
152                        let raw = content_lines[i];
153                        let trimmed_start = raw.trim_start();
154                        let is_blank = raw.trim().is_empty();
155
156                        if is_blank {
157                            if !prev_blank { lines.push(Line::from("")); }
158                            prev_blank = true;
159                            i += 1;
160                            continue;
161                        }
162                        prev_blank = false;
163
164                        if trimmed_start.starts_with("```mermaid") {
165                            in_mermaid = true; in_code = true;
166                            lines.push(Line::from(Span::styled("  ╭─ mermaid ", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD))));
167                        } else if in_mermaid && trimmed_start.starts_with("```") {
168                            in_mermaid = false; in_code = false;
169                            lines.push(Line::from(Span::styled("  ╰────────── ", Style::default().fg(theme::IRIS()))));
170                        } else if in_mermaid {
171                            lines.push(Line::from(vec![
172                                Span::styled("  │ ", Style::default().fg(theme::IRIS())),
173                                Span::styled(raw.trim_end().to_string(), Style::default().fg(theme::GOLD())),
174                            ]));
175                        } else if trimmed_start.starts_with("```") {
176                            if in_code {
177                                in_code = false;
178                                lines.push(Line::from(Span::styled("  ╰──", Style::default().fg(theme::OVERLAY()))));
179                            } else {
180                                in_code = true;
181                                let lang = trimmed_start.trim_start_matches('`').trim();
182                                let label = if lang.is_empty() { "  ╭─ code".to_string() } else { format!("  ╭─ {lang}") };
183                                lines.push(Line::from(Span::styled(label, Style::default().fg(theme::OVERLAY()).add_modifier(Modifier::DIM))));
184                            }
185                        } else if !in_code && is_table_line(raw) {
186                            // Gather the whole contiguous table block and render it
187                            // aligned (columns padded to a common width).
188                            let start = i;
189                            while i < content_lines.len() && is_table_line(content_lines[i]) {
190                                i += 1;
191                            }
192                            lines.extend(render_table_block(&content_lines[start..i]));
193                            continue;
194                        } else {
195                            lines.push(render_md_line(raw, in_code));
196                        }
197                        i += 1;
198                    }
199                    let _ = in_code;
200                }
201            }
202            lines.push(Line::from(""));
203        }
204
205        lines.push(Line::from(""));
206        lines.push(Line::from(""));
207
208        let width = area.width as usize;
209        let total_rows: usize = lines
210            .iter()
211            .map(|l| {
212                let w = l.width();
213                if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
214            })
215            .sum();
216
217        let visible = area.height as usize;
218        self.state.total_lines = total_rows;
219        let max_offset = total_rows.saturating_sub(visible);
220        let offset = if self.state.auto_scroll {
221            self.state.scroll_offset = max_offset;
222            max_offset
223        } else {
224            let o = self.state.scroll_offset.min(max_offset);
225            self.state.scroll_offset = o;
226            o
227        };
228
229        Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
230    }
231}
232
233const LOGO_ART: &[&str] = &[
234    r" ____   _____ __   __ _   _ __  __",
235    r"/ ___| |_   _|\ \ / /| \ | |\ \/ /",
236    r"\___ \   | |   \ V / |  \| | \  / ",
237    r" ___) |  | |    | |  | |\  | /  \ ",
238    r"|____/   |_|    |_|  |_| \_|/_/\_\",
239];
240const LOGO_SUBTITLE: &str = "c   o   d   e";
241
242const HINTS: &[(&str, &str)] = &[
243    ("^P",    "command palette"),
244    ("^T",    "focus tools"),
245    ("^M",    "switch model"),
246    ("/help", "show help"),
247];
248
249fn draw_empty_state(area: Rect, buf: &mut Buffer) {
250    let mut lines: Vec<Line<'static>> = Vec::new();
251
252    let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
253    let pad_to = |s: &str, w: usize| -> String {
254        let n = s.chars().count();
255        if n >= w { s.to_string() } else {
256            let extra = w - n;
257            let left = extra / 2;
258            let right = extra - left;
259            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
260        }
261    };
262
263    let top_pad = area.height.saturating_sub(16) / 3;
264    for _ in 0..top_pad { lines.push(Line::from("")); }
265
266    for art in LOGO_ART {
267        lines.push(Line::from(Span::styled(
268            pad_to(art, logo_w),
269            Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
270        )));
271    }
272    lines.push(Line::from(Span::styled(
273        pad_to(LOGO_SUBTITLE, logo_w),
274        Style::default().fg(theme::ACCENT()),
275    )));
276    lines.push(Line::from(""));
277    lines.push(Line::from(Span::styled(
278        format!("v{}", env!("CARGO_PKG_VERSION")),
279        Style::default().fg(theme::TEXT_MUTED()),
280    )));
281    lines.push(Line::from(""));
282    lines.push(Line::from(Span::styled(
283        "────────────────────".to_string(),
284        Style::default().fg(theme::BORDER()),
285    )));
286    lines.push(Line::from(""));
287
288    let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
289    let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
290    for (k, l) in HINTS {
291        let key = format!("{:>width$}", k, width = key_w);
292        let label = format!("{:<width$}", l, width = label_w);
293        lines.push(Line::from(vec![
294            Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
295            Span::raw("   "),
296            Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
297        ]));
298    }
299    lines.push(Line::from(""));
300    lines.push(Line::from(Span::styled(
301        "type a message to begin…".to_string(),
302        Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
303    )));
304
305    Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
306}