1use ratatui::{
2 prelude::*,
3 widgets::{Block, Borders, List, ListItem, ListState},
4};
5
6const TOOL_OUTPUT_PREVIEW_LEN: usize = 120;
8
9pub struct ChatState {
14 pub messages: Vec<ChatMessage>,
15 pub list_state: ListState,
16}
17
18pub struct ChatMessage {
20 pub text: String,
21 pub timestamp: String,
22 pub expanded: bool,
24}
25
26impl ChatMessage {
27 fn new(text: String) -> Self {
28 let timestamp = chrono_now();
29 Self {
30 text,
31 timestamp,
32 expanded: false,
33 }
34 }
35
36 fn is_collapsible(&self) -> bool {
38 self.text.starts_with(" = ") && self.text.len() > TOOL_OUTPUT_PREVIEW_LEN
39 }
40
41 fn display_text(&self) -> String {
43 if self.is_collapsible() && !self.expanded {
44 let lines: Vec<&str> = self.text.lines().collect();
45 let first_line = lines.first().map_or("", |l| l);
46 let remaining = lines.len().saturating_sub(1);
47 if remaining > 0 {
48 format!("{} [+{} lines]", first_line, remaining)
49 } else {
50 let preview = &self.text[..TOOL_OUTPUT_PREVIEW_LEN.min(self.text.len())];
51 format!("{}... [+more]", preview)
52 }
53 } else {
54 self.text.clone()
55 }
56 }
57}
58
59fn chrono_now() -> String {
60 let now = std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .unwrap_or_default()
63 .as_secs();
64 let hours = (now % 86400) / 3600;
65 let minutes = (now % 3600) / 60;
66 format!("{:02}:{:02}", hours, minutes)
67}
68
69impl ChatState {
70 pub fn new() -> Self {
71 let mut list_state = ListState::default();
72 list_state.select(Some(0));
73 Self {
74 messages: Vec::new(),
75 list_state,
76 }
77 }
78
79 pub fn push(&mut self, msg: String) {
80 self.messages.push(ChatMessage::new(msg));
81 self.scroll_to_bottom();
82 }
83
84 pub fn clear(&mut self) {
85 self.messages.clear();
86 self.list_state.select(Some(0));
87 }
88
89 pub fn scroll_to_bottom(&mut self) {
90 if !self.messages.is_empty() {
91 self.list_state.select(Some(self.messages.len() - 1));
92 }
93 }
94
95 pub fn page_up(&mut self) {
97 let cur = self.list_state.selected().unwrap_or(0);
98 let next = cur.saturating_sub(10);
99 self.list_state.select(Some(next));
100 }
101
102 pub fn page_down(&mut self) {
104 let cur = self.list_state.selected().unwrap_or(0);
105 let max = self.messages.len().saturating_sub(1);
106 let next = (cur + 10).min(max);
107 self.list_state.select(Some(next));
108 }
109
110 pub fn toggle_expand(&mut self) {
112 if let Some(idx) = self.list_state.selected() {
113 if let Some(msg) = self.messages.get_mut(idx) {
114 if msg.is_collapsible() {
115 msg.expanded = !msg.expanded;
116 }
117 }
118 }
119 }
120
121 pub fn replace_or_push(&mut self, prefix: &str, msg: String) {
123 if let Some(last) = self.messages.last_mut() {
124 if last.text.starts_with(prefix) {
125 last.text = msg;
126 return;
127 }
128 }
129 self.push(msg);
130 }
131
132 pub fn append_stream_chunk(&mut self, prefix: &str, chunk: &str) {
135 if let Some(last) = self.messages.last_mut() {
136 if last.text.starts_with(prefix) {
137 last.text.push_str(chunk);
138 self.scroll_to_bottom();
139 return;
140 }
141 }
142 self.push(format!("{}{}", prefix, chunk));
143 }
144
145 pub fn render(&mut self, area: Rect, buf: &mut Buffer, title: &str) {
147 let inner_width = area.width.saturating_sub(2) as usize; let items: Vec<ListItem> = self
150 .messages
151 .iter()
152 .map(|m| {
153 let style = Self::message_style(&m.text);
154 let display = m.display_text();
155
156 let line_text = if m.text.starts_with('=') || m.text.starts_with("Type ") {
158 display
159 } else {
160 format!("[{}] {}", m.timestamp, display)
161 };
162
163 let wrapped = wrap_text(&line_text, inner_width);
165 let lines: Vec<Line> = wrapped
166 .into_iter()
167 .map(|l| Line::from(l).style(style))
168 .collect();
169
170 ListItem::new(Text::from(lines))
171 })
172 .collect();
173
174 let list = List::new(items)
175 .block(Block::default().borders(Borders::ALL).title(title))
176 .highlight_style(Style::default());
177
178 ratatui::widgets::StatefulWidget::render(list, area, buf, &mut self.list_state);
179 }
180
181 fn message_style(msg: &str) -> Style {
183 if msg.starts_with('>') {
184 Style::default().fg(Color::Cyan)
185 } else if msg.starts_with("[THINK]") || msg.starts_with("[STREAM]") {
186 Style::default()
187 .fg(Color::DarkGray)
188 .add_modifier(Modifier::ITALIC)
189 } else if msg.starts_with("[TOOL]") {
190 Style::default().fg(Color::Green)
191 } else if msg.starts_with("[ERR]") {
192 Style::default().fg(Color::Red)
193 } else if msg.starts_with("[WARN]") {
194 Style::default().fg(Color::Yellow)
195 } else if msg.starts_with("[NOTE]") {
196 Style::default().fg(Color::Magenta)
197 } else if msg.starts_with("[DONE]") {
198 Style::default()
199 .fg(Color::Green)
200 .add_modifier(Modifier::BOLD)
201 } else if msg.starts_with("Analysis:") {
202 Style::default().fg(Color::White)
203 } else {
204 Style::default()
205 }
206 }
207}
208
209impl Default for ChatState {
210 fn default() -> Self {
211 Self::new()
212 }
213}
214
215fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
217 if max_width == 0 {
218 return vec![text.to_string()];
219 }
220
221 let mut lines = Vec::new();
222 for input_line in text.lines() {
223 if input_line.len() <= max_width {
224 lines.push(input_line.to_string());
225 } else {
226 let mut remaining = input_line;
227 while remaining.len() > max_width {
228 let break_at = remaining[..max_width]
230 .rfind(' ')
231 .map_or(max_width, |pos| pos + 1);
232 lines.push(remaining[..break_at].trim_end().to_string());
233 remaining = &remaining[break_at..];
234 }
235 if !remaining.is_empty() {
236 lines.push(remaining.to_string());
237 }
238 }
239 }
240
241 if lines.is_empty() {
242 lines.push(String::new());
243 }
244 lines
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn wrap_short_text() {
253 let result = wrap_text("hello world", 80);
254 assert_eq!(result, vec!["hello world"]);
255 }
256
257 #[test]
258 fn wrap_long_text() {
259 let result = wrap_text("the quick brown fox jumps over the lazy dog", 20);
260 assert!(result.len() > 1);
261 for line in &result {
262 assert!(line.len() <= 20);
263 }
264 }
265
266 #[test]
267 fn collapsible_tool_output() {
268 let long_output = format!(" = {}", "x".repeat(200));
269 let msg = ChatMessage::new(long_output.clone());
270 assert!(msg.is_collapsible());
271 assert!(msg.display_text().contains("[+more]"));
272 }
273
274 #[test]
275 fn page_up_down() {
276 let mut chat = ChatState::new();
277 for i in 0..30 {
278 chat.push(format!("msg {}", i));
279 }
280 assert_eq!(chat.list_state.selected(), Some(29));
281 chat.page_up();
282 assert_eq!(chat.list_state.selected(), Some(19));
283 chat.page_down();
284 assert_eq!(chat.list_state.selected(), Some(29));
285 }
286}