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