1use std::collections::HashMap;
4
5use chrono::{DateTime, Local};
6use ratatui::{
7 Frame,
8 layout::{Alignment, Rect},
9 style::{Color, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Padding, Paragraph},
12};
13
14use crate::tui::themes::theme as app_theme;
15use crate::tui::markdown::{render_markdown_with_prefix, wrap_with_prefix};
16
17const USER_PREFIX: &str = "> ";
19const SYSTEM_PREFIX: &str = "* ";
20const TIMESTAMP_PREFIX: &str = " - ";
21const CONTINUATION: &str = " ";
23const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum MessageRole {
29 User,
30 Assistant,
31 System,
32 Tool,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub enum ToolStatus {
38 Executing,
39 WaitingForUser,
40 Completed,
41 Failed(String),
42}
43
44#[derive(Debug, Clone)]
46pub struct ToolMessageData {
47 #[allow(dead_code)] pub tool_use_id: String,
49 pub display_name: String,
50 pub display_title: String,
51 pub status: ToolStatus,
52}
53
54struct Message {
55 role: MessageRole,
56 content: String,
57 timestamp: DateTime<Local>,
58 cached_lines: Option<Vec<Line<'static>>>,
60 cached_width: usize,
62 tool_data: Option<ToolMessageData>,
64}
65
66impl Message {
67 fn new(role: MessageRole, content: String) -> Self {
68 Self {
69 role,
70 content,
71 timestamp: Local::now(),
72 cached_lines: None,
73 cached_width: 0,
74 tool_data: None,
75 }
76 }
77
78 fn new_tool(tool_data: ToolMessageData) -> Self {
79 Self {
80 role: MessageRole::Tool,
81 content: String::new(),
82 timestamp: Local::now(),
83 cached_lines: None,
84 cached_width: 0,
85 tool_data: Some(tool_data),
86 }
87 }
88
89 fn get_rendered_lines(&mut self, available_width: usize) -> &[Line<'static>] {
91 if self.cached_width != available_width {
93 self.cached_lines = None;
94 }
95
96 if self.cached_lines.is_none() {
98 let lines = self.render_lines(available_width);
99 self.cached_lines = Some(lines);
100 self.cached_width = available_width;
101 }
102
103 self.cached_lines.as_ref().unwrap()
104 }
105
106 fn render_lines(&self, available_width: usize) -> Vec<Line<'static>> {
108 let mut lines = Vec::new();
109 let t = app_theme();
110
111 match self.role {
112 MessageRole::User => {
113 let rendered = wrap_with_prefix(
114 &self.content,
115 USER_PREFIX,
116 t.user_prefix,
117 CONTINUATION,
118 available_width,
119 &t,
120 );
121 lines.extend(rendered);
122 }
123 MessageRole::System => {
124 let rendered = wrap_with_prefix(
125 &self.content,
126 SYSTEM_PREFIX,
127 t.system_prefix,
128 CONTINUATION,
129 available_width,
130 &t,
131 );
132 lines.extend(rendered);
133 }
134 MessageRole::Assistant => {
135 let rendered = render_markdown_with_prefix(&self.content, available_width, &t);
136 lines.extend(rendered);
137 }
138 MessageRole::Tool => {
139 if let Some(ref data) = self.tool_data {
140 lines.extend(render_tool_message(data));
141 }
142 }
143 }
144
145 if self.role != MessageRole::Assistant && self.role != MessageRole::Tool {
148 let time_str = self.timestamp.format("%I:%M:%S %p").to_string();
149 let timestamp_text = format!("{}{}", TIMESTAMP_PREFIX, time_str);
150 lines.push(Line::from(vec![Span::styled(
151 timestamp_text,
152 app_theme().timestamp,
153 )]));
154 }
155
156 lines.push(Line::from(""));
158
159 lines
160 }
161}
162
163pub use super::chat_helpers::RenderFn;
165
166pub struct ChatView {
167 messages: Vec<Message>,
168 scroll_offset: u16,
169 streaming_buffer: Option<String>,
171 last_max_scroll: u16,
173 auto_scroll_enabled: bool,
175 tool_index: HashMap<String, usize>,
177 spinner_index: usize,
179 title: String,
181 render_empty_state: Option<RenderFn>,
183}
184
185impl ChatView {
186 pub fn new() -> Self {
188 Self {
189 messages: Vec::new(),
190 scroll_offset: 0,
191 streaming_buffer: None,
192 last_max_scroll: 0,
193 auto_scroll_enabled: true,
194 tool_index: HashMap::new(),
195 spinner_index: 0,
196 title: "Chat".to_string(),
197 render_empty_state: None,
198 }
199 }
200
201 pub fn with_title(mut self, title: impl Into<String>) -> Self {
203 self.title = title.into();
204 self
205 }
206
207 pub fn with_empty_state(mut self, render: RenderFn) -> Self {
215 self.render_empty_state = Some(render);
216 self
217 }
218
219 pub fn set_title(&mut self, title: impl Into<String>) {
221 self.title = title.into();
222 }
223
224 pub fn title(&self) -> &str {
226 &self.title
227 }
228
229 pub fn step_spinner(&mut self) {
231 self.spinner_index = (self.spinner_index + 1) % SPINNER_CHARS.len();
232 }
233
234 pub fn add_user_message(&mut self, content: String) {
236 if !content.trim().is_empty() {
237 self.messages.push(Message::new(MessageRole::User, content));
238 if self.auto_scroll_enabled {
240 self.scroll_offset = u16::MAX;
241 }
242 }
243 }
244
245 pub fn add_assistant_message(&mut self, content: String) {
247 if !content.trim().is_empty() {
248 self.messages
249 .push(Message::new(MessageRole::Assistant, content));
250 if self.auto_scroll_enabled {
252 self.scroll_offset = u16::MAX;
253 }
254 }
255 }
256
257 pub fn add_system_message(&mut self, content: String) {
259 if content.trim().is_empty() {
260 return;
261 }
262 self.messages
263 .push(Message::new(MessageRole::System, content));
264 if self.auto_scroll_enabled {
266 self.scroll_offset = u16::MAX;
267 }
268 }
269
270 pub fn add_tool_message(
272 &mut self,
273 tool_use_id: &str,
274 display_name: &str,
275 display_title: &str,
276 ) {
277 let index = self.messages.len();
278
279 let tool_data = ToolMessageData {
280 tool_use_id: tool_use_id.to_string(),
281 display_name: display_name.to_string(),
282 display_title: display_title.to_string(),
283 status: ToolStatus::Executing,
284 };
285
286 self.messages.push(Message::new_tool(tool_data));
287 self.tool_index.insert(tool_use_id.to_string(), index);
288
289 if self.auto_scroll_enabled {
291 self.scroll_offset = u16::MAX;
292 }
293 }
294
295 pub fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
297 if let Some(&index) = self.tool_index.get(tool_use_id) {
298 if let Some(msg) = self.messages.get_mut(index) {
299 if let Some(ref mut data) = msg.tool_data {
300 data.status = status;
301 msg.cached_lines = None; }
303 }
304 }
305 }
306
307 pub fn enable_auto_scroll(&mut self) {
309 self.auto_scroll_enabled = true;
310 self.scroll_offset = u16::MAX;
311 }
312
313 pub fn append_streaming(&mut self, text: &str) {
315 match &mut self.streaming_buffer {
316 Some(buffer) => buffer.push_str(text),
317 None => self.streaming_buffer = Some(text.to_string()),
318 }
319 if self.auto_scroll_enabled {
321 self.scroll_offset = u16::MAX;
322 }
323 }
324
325 pub fn complete_streaming(&mut self) {
327 if let Some(content) = self.streaming_buffer.take() {
328 if !content.trim().is_empty() {
329 self.messages
330 .push(Message::new(MessageRole::Assistant, content));
331 }
332 }
333 }
334
335 pub fn discard_streaming(&mut self) {
337 self.streaming_buffer = None;
338 }
339
340 pub fn is_streaming(&self) -> bool {
342 self.streaming_buffer.is_some()
343 }
344
345 pub fn scroll_up(&mut self) {
346 if self.scroll_offset == u16::MAX {
348 self.scroll_offset = self.last_max_scroll;
349 }
350 self.scroll_offset = self.scroll_offset.saturating_sub(3);
351 self.auto_scroll_enabled = false;
353 }
354
355 pub fn scroll_down(&mut self) {
356 if self.scroll_offset == u16::MAX {
358 return;
359 }
360 self.scroll_offset = self.scroll_offset.saturating_add(3);
361 if self.scroll_offset >= self.last_max_scroll {
363 self.scroll_offset = u16::MAX;
364 self.auto_scroll_enabled = true; }
366 }
367
368 pub fn render_chat(&mut self, frame: &mut Frame, area: Rect, pending_status: Option<&str>) {
369 let check_style = Style::default().fg(Color::Green);
371
372 let create_titles = || {
374 let left = Line::from(vec![
375 Span::styled("\u{2500} ", app_theme().title_separator),
376 Span::styled("\u{25CF} ", app_theme().title_indicator_connected),
377 Span::styled(self.title.clone(), app_theme().title_text),
378 ]);
379
380 let right = Line::from(vec![
381 Span::styled("[\u{2713}]", check_style),
382 Span::styled(" Manager Agent (1) ", app_theme().title_text),
383 Span::styled("[\u{2713}]", check_style),
384 Span::styled(" Coding Agents (4) ", app_theme().title_text),
385 Span::styled("[\u{2713}]", check_style),
386 Span::styled(" Code Reviewers (2) ", app_theme().title_text),
387 Span::styled("\u{2500}", app_theme().title_separator),
388 ]);
389
390 (left, right)
391 };
392
393 let is_empty_state = self.messages.is_empty() && self.streaming_buffer.is_none() && pending_status.is_none();
395
396 if is_empty_state {
398 if let Some(ref render_fn) = self.render_empty_state {
399 let (left_title, right_title) = create_titles();
400
401 let empty_block = Block::default()
402 .title(left_title)
403 .title_alignment(Alignment::Left)
404 .title(right_title.alignment(Alignment::Right))
405 .borders(Borders::TOP)
406 .border_style(app_theme().border)
407 .padding(Padding::new(1, 0, 1, 0));
408
409 let inner = empty_block.inner(area);
410 frame.render_widget(empty_block, area);
411
412 render_fn(frame, inner, &app_theme());
414 return;
415 }
416 }
417
418 let (left_title, right_title) = create_titles();
420
421 let content_block = Block::default()
422 .title(left_title)
423 .title_alignment(Alignment::Left)
424 .title(right_title.alignment(Alignment::Right))
425 .borders(Borders::TOP)
426 .border_style(app_theme().border)
427 .padding(Padding::new(1, 0, 1, 0)); let available_width = area.width.saturating_sub(2) as usize; let mut message_lines: Vec<Line> = Vec::new();
434
435 if is_empty_state {
437 message_lines.push(Line::from(""));
438 message_lines.push(Line::from(Span::styled(
439 " Type a message to start chatting...",
440 Style::default().fg(Color::DarkGray),
441 )));
442 }
443
444 for msg in &mut self.messages {
445 let cached = msg.get_rendered_lines(available_width);
447 message_lines.extend(cached.iter().cloned());
448 }
449
450 if let Some(ref buffer) = self.streaming_buffer {
452 let rendered = render_markdown_with_prefix(buffer, available_width, &app_theme());
453 message_lines.extend(rendered);
454 if let Some(last) = message_lines.last_mut() {
456 last.spans
457 .push(Span::styled("\u{2588}", app_theme().cursor));
458 }
459 } else if let Some(status) = pending_status {
460 let spinner_char = SPINNER_CHARS[self.spinner_index];
462 message_lines.push(Line::from(vec![
463 Span::styled(format!("{} ", spinner_char), app_theme().throbber_spinner),
464 Span::styled(status, app_theme().throbber_label),
465 ]));
466 }
467
468 let available_height = area.height.saturating_sub(2) as usize; let total_lines = message_lines.len();
471 let max_scroll = total_lines.saturating_sub(available_height) as u16;
472 self.last_max_scroll = max_scroll;
473
474 let scroll_offset = if self.scroll_offset == u16::MAX {
482 max_scroll
483 } else {
484 let clamped = self.scroll_offset.min(max_scroll);
485 if clamped != self.scroll_offset {
486 self.scroll_offset = clamped;
487 }
488 clamped
489 };
490
491 let messages_widget = Paragraph::new(message_lines)
492 .block(content_block)
493 .style(app_theme().background.patch(app_theme().text))
494 .scroll((scroll_offset, 0));
495 frame.render_widget(messages_widget, area);
496 }
497}
498
499fn render_tool_message(data: &ToolMessageData) -> Vec<Line<'static>> {
501 let mut lines = Vec::new();
502
503 let header = if data.display_title.is_empty() {
505 format!("\u{2692} {}", data.display_name)
506 } else {
507 format!("\u{2692} {}({})", data.display_name, data.display_title)
508 };
509 lines.push(Line::from(Span::styled(header, app_theme().tool_header)));
510
511 let status_line = match &data.status {
513 ToolStatus::Executing => Line::from(Span::styled(
514 " \u{2192} executing...".to_string(),
515 app_theme().tool_executing,
516 )),
517 ToolStatus::WaitingForUser => Line::from(Span::styled(
518 " \u{2192} waiting for user...".to_string(),
519 app_theme().tool_executing,
520 )),
521 ToolStatus::Completed => Line::from(Span::styled(
522 " \u{2713} Completed".to_string(),
523 app_theme().tool_completed,
524 )),
525 ToolStatus::Failed(err) => Line::from(Span::styled(
526 format!(" \u{26A0} {}", err),
527 app_theme().tool_failed,
528 )),
529 };
530 lines.push(status_line);
531
532 lines
533}
534
535impl Default for ChatView {
536 fn default() -> Self {
537 Self::new()
538 }
539}
540
541use std::any::Any;
544use crossterm::event::KeyEvent;
545use crate::tui::themes::Theme;
546use super::{widget_ids, Widget, WidgetKeyResult};
547
548impl Widget for ChatView {
549 fn id(&self) -> &'static str {
550 widget_ids::CHAT_VIEW
551 }
552
553 fn priority(&self) -> u8 {
554 50 }
556
557 fn is_active(&self) -> bool {
558 true }
560
561 fn handle_key(&mut self, _key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
562 WidgetKeyResult::NotHandled
565 }
566
567 fn render(&self, frame: &mut Frame, area: Rect, _theme: &Theme) {
568 let mut chat_view = self.clone_for_render();
571 chat_view.render_chat(frame, area, None);
572 }
573
574 fn required_height(&self, _available: u16) -> u16 {
575 0 }
577
578 fn blocks_input(&self) -> bool {
579 false
580 }
581
582 fn is_overlay(&self) -> bool {
583 false
584 }
585
586 fn as_any(&self) -> &dyn Any {
587 self
588 }
589
590 fn as_any_mut(&mut self) -> &mut dyn Any {
591 self
592 }
593
594 fn into_any(self: Box<Self>) -> Box<dyn Any> {
595 self
596 }
597}
598
599impl ChatView {
600 fn clone_for_render(&self) -> Self {
602 Self {
603 messages: Vec::new(), scroll_offset: self.scroll_offset,
605 streaming_buffer: self.streaming_buffer.clone(),
606 last_max_scroll: self.last_max_scroll,
607 auto_scroll_enabled: self.auto_scroll_enabled,
608 tool_index: HashMap::new(),
609 spinner_index: self.spinner_index,
610 title: self.title.clone(),
611 render_empty_state: None, }
613 }
614}