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;
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        let pad = 1u16;
41        let area = Rect {
42            x: area.x + pad,
43            width: area.width.saturating_sub(pad * 2),
44            y: area.y + 1,
45            height: area.height.saturating_sub(1),
46        };
47
48        if self.state.messages.is_empty() {
49            draw_empty_state(area, buf);
50            return;
51        }
52
53        let mut lines: Vec<Line<'static>> = Vec::new();
54
55        let bar = "▌";
56        let body_indent = "  ";
57        let thinking_active = self.thinking_active;
58        let spin = super::spinner::FRAMES[self.spinner_frame % super::spinner::FRAMES.len()];
59
60        for msg in &self.state.messages {
61            match msg.role.as_str() {
62                "user" => {
63                    lines.push(Line::from(vec![
64                        Span::styled(bar, Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)),
65                        Span::styled(" You", Style::default().fg(theme::FOAM()).add_modifier(Modifier::BOLD)),
66                    ]));
67                    for raw in msg.content.lines() {
68                        lines.push(Line::from(Span::styled(
69                            format!("{body_indent}{}", raw.trim_end()),
70                            Style::default().fg(theme::TEXT()),
71                        )));
72                    }
73                }
74                "error" => {
75                    lines.push(Line::from(vec![
76                        Span::styled(bar, Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
77                        Span::styled(" Error", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
78                    ]));
79                    for raw in msg.content.lines() {
80                        lines.push(Line::from(Span::styled(
81                            format!("{body_indent}{}", raw.trim_end()),
82                            Style::default().fg(theme::LOVE()),
83                        )));
84                    }
85                }
86                "system" => {
87                    for raw in msg.content.lines() {
88                        lines.push(Line::from(vec![
89                            Span::styled(bar, Style::default().fg(theme::SUBTLE())),
90                            Span::styled(
91                                format!(" {}", raw.trim_end()),
92                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
93                            ),
94                        ]));
95                    }
96                }
97                "done" => {
98                    let parts: Vec<&str> = msg.content.split(", ").collect();
99                    for (i, part) in parts.iter().enumerate() {
100                        let prefix = if i == 0 { "  ✓ " } else { "    " };
101                        let prefix_style = if i == 0 {
102                            Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD)
103                        } else {
104                            Style::default()
105                        };
106                        lines.push(Line::from(vec![
107                            Span::styled(prefix, prefix_style),
108                            Span::styled(
109                                part.to_string(),
110                                Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
111                            ),
112                        ]));
113                    }
114                }
115                _ => {
116                    lines.push(Line::from(vec![
117                        Span::styled(bar, Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD)),
118                        Span::styled(" Stynx", Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD)),
119                    ]));
120                    if msg.is_streaming && thinking_active && msg.content.trim().is_empty() {
121                        lines.push(Line::from(vec![
122                            Span::styled(
123                                format!("  {spin} "),
124                                Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD),
125                            ),
126                            Span::styled(
127                                "thinking…",
128                                Style::default()
129                                    .fg(theme::MUTED())
130                                    .add_modifier(Modifier::ITALIC),
131                            ),
132                        ]));
133                        lines.push(Line::from(""));
134                    }
135                    if !msg.thinking.is_empty() && !msg.is_streaming {
136                        let lc = msg.thinking.lines().count();
137                        lines.push(Line::from(Span::styled(
138                            format!("  ◈ thinking · {lc} lines"),
139                            Style::default()
140                                .fg(theme::MUTED())
141                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
142                        )));
143                        lines.push(Line::from(""));
144                    }
145                    let mut in_code = false;
146                    let mut in_mermaid = false;
147                    let mut prev_blank = false;
148                    for raw in msg.content.lines() {
149                        let trimmed_start = raw.trim_start();
150                        let is_blank = raw.trim().is_empty();
151
152                        if is_blank {
153                            if !prev_blank { lines.push(Line::from("")); }
154                            prev_blank = true;
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 {
181                            lines.push(render_md_line(raw, in_code));
182                        }
183                    }
184                    let _ = in_code;
185                }
186            }
187            lines.push(Line::from(""));
188        }
189
190        lines.push(Line::from(""));
191        lines.push(Line::from(""));
192
193        let width = area.width as usize;
194        let total_rows: usize = lines
195            .iter()
196            .map(|l| {
197                let w = l.width();
198                if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
199            })
200            .sum();
201
202        let visible = area.height as usize;
203        self.state.total_lines = total_rows;
204        let max_offset = total_rows.saturating_sub(visible);
205        let offset = if self.state.auto_scroll {
206            self.state.scroll_offset = max_offset;
207            max_offset
208        } else {
209            let o = self.state.scroll_offset.min(max_offset);
210            self.state.scroll_offset = o;
211            o
212        };
213
214        Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
215    }
216}
217
218const LOGO_ART: &[&str] = &[
219    r" ____   _____ __   __ _   _ __  __",
220    r"/ ___| |_   _|\ \ / /| \ | |\ \/ /",
221    r"\___ \   | |   \ V / |  \| | \  / ",
222    r" ___) |  | |    | |  | |\  | /  \ ",
223    r"|____/   |_|    |_|  |_| \_|/_/\_\",
224];
225const LOGO_SUBTITLE: &str = "c   o   d   e";
226
227const HINTS: &[(&str, &str)] = &[
228    ("^P",    "command palette"),
229    ("^T",    "focus tools"),
230    ("^M",    "switch model"),
231    ("/help", "show help"),
232];
233
234fn draw_empty_state(area: Rect, buf: &mut Buffer) {
235    let mut lines: Vec<Line<'static>> = Vec::new();
236
237    let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
238    let pad_to = |s: &str, w: usize| -> String {
239        let n = s.chars().count();
240        if n >= w { s.to_string() } else {
241            let extra = w - n;
242            let left = extra / 2;
243            let right = extra - left;
244            format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
245        }
246    };
247
248    let top_pad = area.height.saturating_sub(16) / 3;
249    for _ in 0..top_pad { lines.push(Line::from("")); }
250
251    for art in LOGO_ART {
252        lines.push(Line::from(Span::styled(
253            pad_to(art, logo_w),
254            Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
255        )));
256    }
257    lines.push(Line::from(Span::styled(
258        pad_to(LOGO_SUBTITLE, logo_w),
259        Style::default().fg(theme::ACCENT()),
260    )));
261    lines.push(Line::from(""));
262    lines.push(Line::from(Span::styled(
263        format!("v{}", env!("CARGO_PKG_VERSION")),
264        Style::default().fg(theme::TEXT_MUTED()),
265    )));
266    lines.push(Line::from(""));
267    lines.push(Line::from(Span::styled(
268        "────────────────────".to_string(),
269        Style::default().fg(theme::BORDER()),
270    )));
271    lines.push(Line::from(""));
272
273    let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
274    let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
275    for (k, l) in HINTS {
276        let key = format!("{:>width$}", k, width = key_w);
277        let label = format!("{:<width$}", l, width = label_w);
278        lines.push(Line::from(vec![
279            Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
280            Span::raw("   "),
281            Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
282        ]));
283    }
284    lines.push(Line::from(""));
285    lines.push(Line::from(Span::styled(
286        "type a message to begin…".to_string(),
287        Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
288    )));
289
290    Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
291}