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}