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_wrapped, 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.extend(render_md_line_wrapped(raw, in_code, (area.width as usize).saturating_sub(1)));
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
216 if total_rows < visible {
220 let pad = visible - total_rows;
221 let mut anchored: Vec<Line<'static>> = Vec::with_capacity(pad + lines.len());
222 anchored.resize(pad, Line::from(""));
223 anchored.extend(lines);
224 self.state.scroll_offset = 0;
225 Paragraph::new(anchored).wrap(Wrap { trim: false }).render(area, buf);
226 return;
227 }
228
229 let max_offset = total_rows.saturating_sub(visible);
230 let offset = if self.state.auto_scroll {
231 self.state.scroll_offset = max_offset;
232 max_offset
233 } else {
234 let o = self.state.scroll_offset.min(max_offset);
235 self.state.scroll_offset = o;
236 o
237 };
238
239 Paragraph::new(lines).scroll((offset as u16, 0)).wrap(Wrap { trim: false }).render(area, buf);
240 }
241}
242
243fn stamp_role_bar(lines: &mut [Line<'static>], start: usize, color: Color) {
247 let line = match lines.get_mut(start) {
248 Some(l) => l,
249 None => return,
250 };
251 if let Some(first) = line.spans.first_mut() {
252 let trimmed = first
253 .content
254 .strip_prefix(" ")
255 .or_else(|| first.content.strip_prefix(' '))
256 .map(|s| s.to_string());
257 if let Some(t) = trimmed {
258 first.content = t.into();
259 }
260 }
261 line.spans.insert(
262 0,
263 Span::styled("▌ ", Style::default().fg(color).add_modifier(Modifier::BOLD)),
264 );
265}
266
267const LOGO_ART: &[&str] = &[
268 r" ____ _____ __ __ _ _ __ __",
269 r"/ ___| |_ _|\ \ / /| \ | |\ \/ /",
270 r"\___ \ | | \ V / | \| | \ / ",
271 r" ___) | | | | | | |\ | / \ ",
272 r"|____/ |_| |_| |_| \_|/_/\_\",
273];
274const LOGO_SUBTITLE: &str = "c o d e";
275
276const HINTS: &[(&str, &str)] = &[
277 ("^P", "command palette"),
278 ("^T", "focus tools"),
279 ("^M", "switch model"),
280 ("/help", "show help"),
281];
282
283fn draw_empty_state(area: Rect, buf: &mut Buffer) {
284 let mut lines: Vec<Line<'static>> = Vec::new();
285
286 let logo_w = LOGO_ART.iter().map(|s| s.chars().count()).max().unwrap_or(0);
287 let pad_to = |s: &str, w: usize| -> String {
288 let n = s.chars().count();
289 if n >= w { s.to_string() } else {
290 let extra = w - n;
291 let left = extra / 2;
292 let right = extra - left;
293 format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
294 }
295 };
296
297 let top_pad = area.height.saturating_sub(16) / 3;
298 for _ in 0..top_pad { lines.push(Line::from("")); }
299
300 for art in LOGO_ART {
301 lines.push(Line::from(Span::styled(
302 pad_to(art, logo_w),
303 Style::default().fg(theme::PRIMARY()).add_modifier(Modifier::BOLD),
304 )));
305 }
306 lines.push(Line::from(Span::styled(
307 pad_to(LOGO_SUBTITLE, logo_w),
308 Style::default().fg(theme::ACCENT()),
309 )));
310 lines.push(Line::from(""));
311 lines.push(Line::from(Span::styled(
312 format!("v{}", env!("CARGO_PKG_VERSION")),
313 Style::default().fg(theme::TEXT_MUTED()),
314 )));
315 lines.push(Line::from(""));
316 lines.push(Line::from(Span::styled(
317 "────────────────────".to_string(),
318 Style::default().fg(theme::BORDER()),
319 )));
320 lines.push(Line::from(""));
321
322 let key_w = HINTS.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
323 let label_w = HINTS.iter().map(|(_, l)| l.chars().count()).max().unwrap_or(0);
324 for (k, l) in HINTS {
325 let key = format!("{:>width$}", k, width = key_w);
326 let label = format!("{:<width$}", l, width = label_w);
327 lines.push(Line::from(vec![
328 Span::styled(key, Style::default().fg(theme::ACCENT()).add_modifier(Modifier::BOLD)),
329 Span::raw(" "),
330 Span::styled(label, Style::default().fg(theme::TEXT_MUTED())),
331 ]));
332 }
333 lines.push(Line::from(""));
334 lines.push(Line::from(Span::styled(
335 "type a message to begin…".to_string(),
336 Style::default().fg(theme::TEXT_MUTED()).add_modifier(Modifier::ITALIC),
337 )));
338
339 Paragraph::new(lines).alignment(Alignment::Center).render(area, buf);
340}