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