1use ratatui::{
2 buffer::Buffer,
3 layout::Rect,
4 style::{Color, 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 let start = lines.len();
65 for raw in msg.content.lines() {
66 lines.push(Line::from(Span::styled(
67 format!("{body_indent}{}", raw.trim_end()),
68 Style::default().fg(theme::TEXT()),
69 )));
70 }
71 stamp_role_bar(&mut lines, start, theme::FOAM());
72 }
73 "error" => {
74 lines.push(Line::from(vec![
75 Span::styled(bar, Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
76 Span::styled(" Error", Style::default().fg(theme::LOVE()).add_modifier(Modifier::BOLD)),
77 ]));
78 for raw in msg.content.lines() {
79 lines.push(Line::from(Span::styled(
80 format!("{body_indent}{}", raw.trim_end()),
81 Style::default().fg(theme::LOVE()),
82 )));
83 }
84 }
85 "system" => {
86 for raw in msg.content.lines() {
87 lines.push(Line::from(vec![
88 Span::styled(bar, Style::default().fg(theme::SUBTLE())),
89 Span::styled(
90 format!(" {}", raw.trim_end()),
91 Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
92 ),
93 ]));
94 }
95 }
96 "done" => {
97 let parts: Vec<&str> = msg.content.split(", ").collect();
98 for (i, part) in parts.iter().enumerate() {
99 let prefix = if i == 0 { " ✓ " } else { " " };
100 let prefix_style = if i == 0 {
101 Style::default().fg(theme::SUCCESS()).add_modifier(Modifier::BOLD)
102 } else {
103 Style::default()
104 };
105 lines.push(Line::from(vec![
106 Span::styled(prefix, prefix_style),
107 Span::styled(
108 part.to_string(),
109 Style::default().fg(theme::MUTED()).add_modifier(Modifier::ITALIC),
110 ),
111 ]));
112 }
113 }
114 _ => {
115 let asst_start = lines.len();
116 if msg.is_streaming && thinking_active && msg.content.trim().is_empty() {
117 lines.push(Line::from(vec![
118 Span::styled(
119 format!(" {spin} "),
120 Style::default().fg(theme::IRIS()).add_modifier(Modifier::BOLD),
121 ),
122 Span::styled(
123 "thinking…",
124 Style::default()
125 .fg(theme::MUTED())
126 .add_modifier(Modifier::ITALIC),
127 ),
128 ]));
129 lines.push(Line::from(""));
130 }
131 if !msg.thinking.is_empty() && !msg.is_streaming {
132 let lc = msg.thinking.lines().count();
133 lines.push(Line::from(Span::styled(
134 format!(" ◈ thinking · {lc} lines"),
135 Style::default()
136 .fg(theme::MUTED())
137 .add_modifier(Modifier::ITALIC | Modifier::DIM),
138 )));
139 lines.push(Line::from(""));
140 }
141 let content_lines: Vec<&str> = msg.content.lines().collect();
142 let mut in_code = false;
143 let mut in_mermaid = false;
144 let mut prev_blank = false;
145 let mut i = 0;
146 while i < content_lines.len() {
147 let raw = content_lines[i];
148 let trimmed_start = raw.trim_start();
149 let is_blank = raw.trim().is_empty();
150
151 if is_blank {
152 if !prev_blank { lines.push(Line::from("")); }
153 prev_blank = true;
154 i += 1;
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 if !in_code && is_table_line(raw) {
181 let start = i;
184 while i < content_lines.len() && is_table_line(content_lines[i]) {
185 i += 1;
186 }
187 lines.extend(render_table_block(&content_lines[start..i], area.width as usize));
188 continue;
189 } else {
190 lines.push(render_md_line(raw, in_code));
191 }
192 i += 1;
193 }
194 let _ = in_code;
195 stamp_role_bar(&mut lines, asst_start, theme::IRIS());
196 }
197 }
198 lines.push(Line::from(""));
199 }
200
201 lines.push(Line::from(""));
202 lines.push(Line::from(""));
203
204 let width = area.width as usize;
205 let total_rows: usize = lines
206 .iter()
207 .map(|l| {
208 let w = l.width();
209 if w == 0 || width == 0 { 1 } else { (w + width - 1) / width }
210 })
211 .sum();
212
213 let visible = area.height as usize;
214 self.state.total_lines = total_rows;
215 let max_offset = total_rows.saturating_sub(visible);
216 let offset = if self.state.auto_scroll {
217 self.state.scroll_offset = max_offset;
218 max_offset
219 } else {
220 let o = self.state.scroll_offset.min(max_offset);
221 self.state.scroll_offset = o;
222 o
223 };
224
225 Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
226 }
227}
228
229fn stamp_role_bar(lines: &mut [Line<'static>], start: usize, color: Color) {
233 let line = match lines.get_mut(start) {
234 Some(l) => l,
235 None => return,
236 };
237 if let Some(first) = line.spans.first_mut() {
238 let trimmed = first
239 .content
240 .strip_prefix(" ")
241 .or_else(|| first.content.strip_prefix(' '))
242 .map(|s| s.to_string());
243 if let Some(t) = trimmed {
244 first.content = t.into();
245 }
246 }
247 line.spans.insert(
248 0,
249 Span::styled("▌ ", Style::default().fg(color).add_modifier(Modifier::BOLD)),
250 );
251}
252
253const LOGO_ART: &[&str] = &[
254 r" ____ _____ __ __ _ _ __ __",
255 r"/ ___| |_ _|\ \ / /| \ | |\ \/ /",
256 r"\___ \ | | \ V / | \| | \ / ",
257 r" ___) | | | | | | |\ | / \ ",
258 r"|____/ |_| |_| |_| \_|/_/\_\",
259];
260const LOGO_SUBTITLE: &str = "c o d e";
261
262const HINTS: &[(&str, &str)] = &[
263 ("^P", "command palette"),
264 ("^T", "focus tools"),
265 ("^M", "switch model"),
266 ("/help", "show help"),
267];
268
269fn draw_empty_state(area: Rect, buf: &mut Buffer) {
270 let mut lines: Vec<Line<'static>> = Vec::new();
271
272 let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
273 let pad_to = |s: &str, w: usize| -> String {
274 let n = s.chars().count();
275 if n >= w { s.to_string() } else {
276 let extra = w - n;
277 let left = extra / 2;
278 let right = extra - left;
279 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
280 }
281 };
282
283 let top_pad = area.height.saturating_sub(16) / 3;
284 for _ in 0..top_pad { lines.push(Line::from("")); }
285
286 for art in LOGO_ART {
287 lines.push(Line::from(Span::styled(
288 pad_to(art, logo_w),
289 Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
290 )));
291 }
292 lines.push(Line::from(Span::styled(
293 pad_to(LOGO_SUBTITLE, logo_w),
294 Style::default().fg(theme::ACCENT()),
295 )));
296 lines.push(Line::from(""));
297 lines.push(Line::from(Span::styled(
298 format!("v{}", env!("CARGO_PKG_VERSION")),
299 Style::default().fg(theme::TEXT_MUTED()),
300 )));
301 lines.push(Line::from(""));
302 lines.push(Line::from(Span::styled(
303 "────────────────────".to_string(),
304 Style::default().fg(theme::BORDER()),
305 )));
306 lines.push(Line::from(""));
307
308 let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
309 let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
310 for (k, l) in HINTS {
311 let key = format!("{:>width$}", k, width = key_w);
312 let label = format!("{:<width$}", l, width = label_w);
313 lines.push(Line::from(vec![
314 Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
315 Span::raw(" "),
316 Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
317 ]));
318 }
319 lines.push(Line::from(""));
320 lines.push(Line::from(Span::styled(
321 "type a message to begin…".to_string(),
322 Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
323 )));
324
325 Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
326}