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 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 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}