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