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
17pub mod defaults {
19 pub const USER_PREFIX: &str = "> ";
21 pub const SYSTEM_PREFIX: &str = "* ";
23 pub const TIMESTAMP_PREFIX: &str = " - ";
25 pub const CONTINUATION: &str = " ";
27 pub const SPINNER_CHARS: &[char] = &['\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}', '\u{2807}', '\u{280F}'];
29 pub const DEFAULT_TITLE: &str = "Chat";
31 pub const DEFAULT_EMPTY_MESSAGE: &str = " Type a message to start chatting...";
33 pub const TOOL_ICON: &str = "\u{2692}";
35 pub const TOOL_EXECUTING_ARROW: &str = "\u{2192}";
37 pub const TOOL_COMPLETED_CHECKMARK: &str = "\u{2713}";
39 pub const TOOL_FAILED_ICON: &str = "\u{26A0}";
41}
42
43#[derive(Clone)]
55pub struct ChatViewConfig {
56 pub user_prefix: String,
58 pub system_prefix: String,
60 pub timestamp_prefix: String,
62 pub continuation: String,
64 pub spinner_chars: Vec<char>,
66 pub default_title: String,
68 pub empty_message: String,
70 pub tool_icon: String,
72 pub tool_executing_arrow: String,
74 pub tool_completed_checkmark: String,
76 pub tool_failed_icon: String,
78}
79
80impl Default for ChatViewConfig {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl ChatViewConfig {
87 pub fn new() -> Self {
89 Self {
90 user_prefix: defaults::USER_PREFIX.to_string(),
91 system_prefix: defaults::SYSTEM_PREFIX.to_string(),
92 timestamp_prefix: defaults::TIMESTAMP_PREFIX.to_string(),
93 continuation: defaults::CONTINUATION.to_string(),
94 spinner_chars: defaults::SPINNER_CHARS.to_vec(),
95 default_title: defaults::DEFAULT_TITLE.to_string(),
96 empty_message: defaults::DEFAULT_EMPTY_MESSAGE.to_string(),
97 tool_icon: defaults::TOOL_ICON.to_string(),
98 tool_executing_arrow: defaults::TOOL_EXECUTING_ARROW.to_string(),
99 tool_completed_checkmark: defaults::TOOL_COMPLETED_CHECKMARK.to_string(),
100 tool_failed_icon: defaults::TOOL_FAILED_ICON.to_string(),
101 }
102 }
103
104 pub fn with_user_prefix(mut self, prefix: impl Into<String>) -> Self {
106 self.user_prefix = prefix.into();
107 self
108 }
109
110 pub fn with_system_prefix(mut self, prefix: impl Into<String>) -> Self {
112 self.system_prefix = prefix.into();
113 self
114 }
115
116 pub fn with_timestamp_prefix(mut self, prefix: impl Into<String>) -> Self {
118 self.timestamp_prefix = prefix.into();
119 self
120 }
121
122 pub fn with_continuation(mut self, continuation: impl Into<String>) -> Self {
124 self.continuation = continuation.into();
125 self
126 }
127
128 pub fn with_spinner_chars(mut self, chars: &[char]) -> Self {
130 self.spinner_chars = chars.to_vec();
131 self
132 }
133
134 pub fn with_default_title(mut self, title: impl Into<String>) -> Self {
136 self.default_title = title.into();
137 self
138 }
139
140 pub fn with_empty_message(mut self, message: impl Into<String>) -> Self {
142 self.empty_message = message.into();
143 self
144 }
145
146 pub fn with_tool_icon(mut self, icon: impl Into<String>) -> Self {
148 self.tool_icon = icon.into();
149 self
150 }
151
152 pub fn with_tool_status_icons(
154 mut self,
155 executing_arrow: impl Into<String>,
156 completed_checkmark: impl Into<String>,
157 failed_icon: impl Into<String>,
158 ) -> Self {
159 self.tool_executing_arrow = executing_arrow.into();
160 self.tool_completed_checkmark = completed_checkmark.into();
161 self.tool_failed_icon = failed_icon.into();
162 self
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq)]
168pub enum MessageRole {
169 User,
170 Assistant,
171 System,
172 Tool,
173}
174
175#[derive(Debug, Clone, PartialEq)]
177pub enum ToolStatus {
178 Executing,
179 WaitingForUser,
180 Completed,
181 Failed(String),
182}
183
184#[derive(Debug, Clone)]
187pub struct ToolMessageData {
188 #[allow(dead_code)] pub tool_use_id: String,
191 pub display_name: String,
193 pub display_title: String,
195 pub status: ToolStatus,
197}
198
199struct Message {
200 role: MessageRole,
201 content: String,
202 timestamp: DateTime<Local>,
203 cached_lines: Option<Vec<Line<'static>>>,
205 cached_width: usize,
207 tool_data: Option<ToolMessageData>,
209}
210
211impl Message {
212 fn new(role: MessageRole, content: String) -> Self {
213 Self {
214 role,
215 content,
216 timestamp: Local::now(),
217 cached_lines: None,
218 cached_width: 0,
219 tool_data: None,
220 }
221 }
222
223 fn new_tool(tool_data: ToolMessageData) -> Self {
224 Self {
225 role: MessageRole::Tool,
226 content: String::new(),
227 timestamp: Local::now(),
228 cached_lines: None,
229 cached_width: 0,
230 tool_data: Some(tool_data),
231 }
232 }
233
234 fn get_rendered_lines(&mut self, available_width: usize, config: &ChatViewConfig) -> &[Line<'static>] {
236 if self.cached_width != available_width {
238 self.cached_lines = None;
239 }
240
241 if self.cached_lines.is_none() {
243 let lines = self.render_lines(available_width, config);
244 self.cached_lines = Some(lines);
245 self.cached_width = available_width;
246 }
247
248 self.cached_lines.as_ref().unwrap()
249 }
250
251 fn render_lines(&self, available_width: usize, config: &ChatViewConfig) -> Vec<Line<'static>> {
253 let mut lines = Vec::new();
254 let t = app_theme();
255
256 match self.role {
257 MessageRole::User => {
258 let rendered = wrap_with_prefix(
259 &self.content,
260 &config.user_prefix,
261 t.user_prefix,
262 &config.continuation,
263 available_width,
264 &t,
265 );
266 lines.extend(rendered);
267 }
268 MessageRole::System => {
269 let rendered = wrap_with_prefix(
270 &self.content,
271 &config.system_prefix,
272 t.system_prefix,
273 &config.continuation,
274 available_width,
275 &t,
276 );
277 lines.extend(rendered);
278 }
279 MessageRole::Assistant => {
280 let rendered = render_markdown_with_prefix(&self.content, available_width, &t);
281 lines.extend(rendered);
282 }
283 MessageRole::Tool => {
284 if let Some(ref data) = self.tool_data {
285 lines.extend(render_tool_message(data, config));
286 }
287 }
288 }
289
290 if self.role != MessageRole::Assistant && self.role != MessageRole::Tool {
293 let time_str = self.timestamp.format("%I:%M:%S %p").to_string();
294 let timestamp_text = format!("{}{}", config.timestamp_prefix, time_str);
295 lines.push(Line::from(vec![Span::styled(
296 timestamp_text,
297 app_theme().timestamp,
298 )]));
299 }
300
301 lines.push(Line::from(""));
303
304 lines
305 }
306}
307
308pub use super::chat_helpers::RenderFn;
310
311use crate::tui::themes::Theme;
312
313pub type TitleRenderFn = Box<dyn Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync>;
316
317pub struct ChatView {
319 messages: Vec<Message>,
320 scroll_offset: u16,
321 streaming_buffer: Option<String>,
323 last_max_scroll: u16,
325 auto_scroll_enabled: bool,
327 tool_index: HashMap<String, usize>,
329 spinner_index: usize,
331 title: String,
333 render_initial_content: Option<RenderFn>,
335 render_title: Option<TitleRenderFn>,
337 config: ChatViewConfig,
339}
340
341impl ChatView {
342 pub fn new() -> Self {
344 Self::with_config(ChatViewConfig::new())
345 }
346
347 pub fn with_config(config: ChatViewConfig) -> Self {
349 let title = config.default_title.clone();
350 Self {
351 messages: Vec::new(),
352 scroll_offset: 0,
353 streaming_buffer: None,
354 last_max_scroll: 0,
355 auto_scroll_enabled: true,
356 tool_index: HashMap::new(),
357 spinner_index: 0,
358 title,
359 render_initial_content: None,
360 render_title: None,
361 config,
362 }
363 }
364
365 pub fn config(&self) -> &ChatViewConfig {
367 &self.config
368 }
369
370 pub fn set_config(&mut self, config: ChatViewConfig) {
372 self.config = config;
373 for msg in &mut self.messages {
375 msg.cached_lines = None;
376 }
377 }
378
379 pub fn with_title(mut self, title: impl Into<String>) -> Self {
381 self.title = title.into();
382 self
383 }
384
385 pub fn with_initial_content(mut self, render: RenderFn) -> Self {
390 self.render_initial_content = Some(render);
391 self
392 }
393
394 pub fn with_title_renderer<F>(mut self, render: F) -> Self
399 where
400 F: Fn(&str, &Theme) -> (Line<'static>, Line<'static>) + Send + Sync + 'static,
401 {
402 self.render_title = Some(Box::new(render));
403 self
404 }
405
406 pub fn set_title(&mut self, title: impl Into<String>) {
408 self.title = title.into();
409 }
410
411 pub fn title(&self) -> &str {
413 &self.title
414 }
415
416 pub fn step_spinner(&mut self) {
418 let len = self.config.spinner_chars.len().max(1);
419 self.spinner_index = (self.spinner_index + 1) % len;
420 }
421
422 pub fn add_user_message(&mut self, content: String) {
424 if !content.trim().is_empty() {
425 self.messages.push(Message::new(MessageRole::User, content));
426 if self.auto_scroll_enabled {
428 self.scroll_offset = u16::MAX;
429 }
430 }
431 }
432
433 pub fn add_assistant_message(&mut self, content: String) {
435 if !content.trim().is_empty() {
436 self.messages
437 .push(Message::new(MessageRole::Assistant, content));
438 if self.auto_scroll_enabled {
440 self.scroll_offset = u16::MAX;
441 }
442 }
443 }
444
445 pub fn add_system_message(&mut self, content: String) {
447 if content.trim().is_empty() {
448 return;
449 }
450 self.messages
451 .push(Message::new(MessageRole::System, content));
452 if self.auto_scroll_enabled {
454 self.scroll_offset = u16::MAX;
455 }
456 }
457
458 pub fn add_tool_message(
460 &mut self,
461 tool_use_id: &str,
462 display_name: &str,
463 display_title: &str,
464 ) {
465 let index = self.messages.len();
466
467 let tool_data = ToolMessageData {
468 tool_use_id: tool_use_id.to_string(),
469 display_name: display_name.to_string(),
470 display_title: display_title.to_string(),
471 status: ToolStatus::Executing,
472 };
473
474 self.messages.push(Message::new_tool(tool_data));
475 self.tool_index.insert(tool_use_id.to_string(), index);
476
477 if self.auto_scroll_enabled {
479 self.scroll_offset = u16::MAX;
480 }
481 }
482
483 pub fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
485 if let Some(&index) = self.tool_index.get(tool_use_id) {
486 if let Some(msg) = self.messages.get_mut(index) {
487 if let Some(ref mut data) = msg.tool_data {
488 data.status = status;
489 msg.cached_lines = None; }
491 }
492 }
493 }
494
495 pub fn enable_auto_scroll(&mut self) {
497 self.auto_scroll_enabled = true;
498 self.scroll_offset = u16::MAX;
499 }
500
501 pub fn append_streaming(&mut self, text: &str) {
503 match &mut self.streaming_buffer {
504 Some(buffer) => buffer.push_str(text),
505 None => self.streaming_buffer = Some(text.to_string()),
506 }
507 if self.auto_scroll_enabled {
509 self.scroll_offset = u16::MAX;
510 }
511 }
512
513 pub fn complete_streaming(&mut self) {
515 if let Some(content) = self.streaming_buffer.take() {
516 if !content.trim().is_empty() {
517 self.messages
518 .push(Message::new(MessageRole::Assistant, content));
519 }
520 }
521 }
522
523 pub fn discard_streaming(&mut self) {
525 self.streaming_buffer = None;
526 }
527
528 pub fn is_streaming(&self) -> bool {
530 self.streaming_buffer.is_some()
531 }
532
533 pub fn scroll_up(&mut self) {
534 if self.scroll_offset == u16::MAX {
536 self.scroll_offset = self.last_max_scroll;
537 }
538 self.scroll_offset = self.scroll_offset.saturating_sub(3);
539 self.auto_scroll_enabled = false;
541 }
542
543 pub fn scroll_down(&mut self) {
544 if self.scroll_offset == u16::MAX {
546 return;
547 }
548 self.scroll_offset = self.scroll_offset.saturating_add(3);
549 if self.scroll_offset >= self.last_max_scroll {
551 self.scroll_offset = u16::MAX;
552 self.auto_scroll_enabled = true; }
554 }
555
556 pub fn render_chat(&mut self, frame: &mut Frame, area: Rect, pending_status: Option<&str>) {
557 let theme = app_theme();
558
559 let content_block = if let Some(ref render_fn) = self.render_title {
561 let (left_title, right_title) = render_fn(&self.title, &theme);
562 Block::default()
563 .title(left_title)
564 .title_alignment(Alignment::Left)
565 .title(right_title.alignment(Alignment::Right))
566 .borders(Borders::TOP)
567 .border_style(theme.border)
568 .padding(Padding::new(1, 0, 1, 0))
569 } else {
570 Block::default()
571 .borders(Borders::TOP)
572 .border_style(theme.border)
573 .padding(Padding::new(1, 0, 1, 0))
574 };
575
576 let is_initial_state = self.messages.is_empty() && self.streaming_buffer.is_none() && pending_status.is_none();
578
579 if is_initial_state {
581 if let Some(ref render_fn) = self.render_initial_content {
582 let inner = content_block.inner(area);
583 frame.render_widget(content_block, area);
584 render_fn(frame, inner, &theme);
585 return;
586 }
587 }
588
589 let available_width = area.width.saturating_sub(2) as usize; let mut message_lines: Vec<Line> = Vec::new();
594
595 if is_initial_state {
597 message_lines.push(Line::from(""));
598 message_lines.push(Line::from(Span::styled(
599 self.config.empty_message.clone(),
600 Style::default().fg(Color::DarkGray),
601 )));
602 }
603
604 for msg in &mut self.messages {
605 let cached = msg.get_rendered_lines(available_width, &self.config);
607 message_lines.extend(cached.iter().cloned());
608 }
609
610 if let Some(ref buffer) = self.streaming_buffer {
612 let rendered = render_markdown_with_prefix(buffer, available_width, &theme);
613 message_lines.extend(rendered);
614 if let Some(last) = message_lines.last_mut() {
616 last.spans
617 .push(Span::styled("\u{2588}", theme.cursor));
618 }
619 } else if let Some(status) = pending_status {
620 let spinner_char = self.config.spinner_chars.get(self.spinner_index).copied().unwrap_or(' ');
622 message_lines.push(Line::from(vec![
623 Span::styled(format!("{} ", spinner_char), theme.throbber_spinner),
624 Span::styled(status, theme.throbber_label),
625 ]));
626 }
627
628 let available_height = area.height.saturating_sub(2) as usize; let total_lines = message_lines.len();
631 let max_scroll = total_lines.saturating_sub(available_height) as u16;
632 self.last_max_scroll = max_scroll;
633
634 let scroll_offset = if self.scroll_offset == u16::MAX {
642 max_scroll
643 } else {
644 let clamped = self.scroll_offset.min(max_scroll);
645 if clamped != self.scroll_offset {
646 self.scroll_offset = clamped;
647 }
648 clamped
649 };
650
651 let messages_widget = Paragraph::new(message_lines)
652 .block(content_block)
653 .style(theme.background.patch(theme.text))
654 .scroll((scroll_offset, 0));
655 frame.render_widget(messages_widget, area);
656 }
657}
658
659fn render_tool_message(data: &ToolMessageData, config: &ChatViewConfig) -> Vec<Line<'static>> {
661 let mut lines = Vec::new();
662
663 let header = if data.display_title.is_empty() {
665 format!("{} {}", config.tool_icon, data.display_name)
666 } else {
667 format!("{} {}({})", config.tool_icon, data.display_name, data.display_title)
668 };
669 lines.push(Line::from(Span::styled(header, app_theme().tool_header)));
670
671 let status_line = match &data.status {
673 ToolStatus::Executing => Line::from(Span::styled(
674 format!(" {} executing...", config.tool_executing_arrow),
675 app_theme().tool_executing,
676 )),
677 ToolStatus::WaitingForUser => Line::from(Span::styled(
678 format!(" {} waiting for user...", config.tool_executing_arrow),
679 app_theme().tool_executing,
680 )),
681 ToolStatus::Completed => Line::from(Span::styled(
682 format!(" {} Completed", config.tool_completed_checkmark),
683 app_theme().tool_completed,
684 )),
685 ToolStatus::Failed(err) => Line::from(Span::styled(
686 format!(" {} {}", config.tool_failed_icon, err),
687 app_theme().tool_failed,
688 )),
689 };
690 lines.push(status_line);
691
692 lines
693}
694
695impl Default for ChatView {
696 fn default() -> Self {
697 Self::new()
698 }
699}
700
701use super::ConversationView;
704
705#[derive(Clone)]
707struct ChatViewState {
708 messages: Vec<MessageSnapshot>,
709 scroll_offset: u16,
710 streaming_buffer: Option<String>,
711 last_max_scroll: u16,
712 auto_scroll_enabled: bool,
713 tool_index: HashMap<String, usize>,
714 spinner_index: usize,
715}
716
717#[derive(Clone)]
719struct MessageSnapshot {
720 role: MessageRole,
721 content: String,
722 timestamp: DateTime<Local>,
723 tool_data: Option<ToolMessageData>,
724}
725
726impl From<&Message> for MessageSnapshot {
727 fn from(msg: &Message) -> Self {
728 Self {
729 role: msg.role,
730 content: msg.content.clone(),
731 timestamp: msg.timestamp,
732 tool_data: msg.tool_data.clone(),
733 }
734 }
735}
736
737impl From<MessageSnapshot> for Message {
738 fn from(snapshot: MessageSnapshot) -> Self {
739 Self {
740 role: snapshot.role,
741 content: snapshot.content,
742 timestamp: snapshot.timestamp,
743 cached_lines: None,
744 cached_width: 0,
745 tool_data: snapshot.tool_data,
746 }
747 }
748}
749
750impl ConversationView for ChatView {
751 fn add_user_message(&mut self, content: String) {
752 ChatView::add_user_message(self, content);
753 }
754
755 fn add_assistant_message(&mut self, content: String) {
756 ChatView::add_assistant_message(self, content);
757 }
758
759 fn add_system_message(&mut self, content: String) {
760 ChatView::add_system_message(self, content);
761 }
762
763 fn append_streaming(&mut self, text: &str) {
764 ChatView::append_streaming(self, text);
765 }
766
767 fn complete_streaming(&mut self) {
768 ChatView::complete_streaming(self);
769 }
770
771 fn discard_streaming(&mut self) {
772 ChatView::discard_streaming(self);
773 }
774
775 fn is_streaming(&self) -> bool {
776 ChatView::is_streaming(self)
777 }
778
779 fn add_tool_message(&mut self, tool_use_id: &str, display_name: &str, display_title: &str) {
780 ChatView::add_tool_message(self, tool_use_id, display_name, display_title);
781 }
782
783 fn update_tool_status(&mut self, tool_use_id: &str, status: ToolStatus) {
784 ChatView::update_tool_status(self, tool_use_id, status);
785 }
786
787 fn scroll_up(&mut self) {
788 ChatView::scroll_up(self);
789 }
790
791 fn scroll_down(&mut self) {
792 ChatView::scroll_down(self);
793 }
794
795 fn enable_auto_scroll(&mut self) {
796 ChatView::enable_auto_scroll(self);
797 }
798
799 fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme, pending_status: Option<&str>) {
800 self.render_chat(frame, area, pending_status);
801 }
802
803 fn step_spinner(&mut self) {
804 ChatView::step_spinner(self);
805 }
806
807 fn save_state(&self) -> Box<dyn Any + Send> {
808 let state = ChatViewState {
809 messages: self.messages.iter().map(MessageSnapshot::from).collect(),
810 scroll_offset: self.scroll_offset,
811 streaming_buffer: self.streaming_buffer.clone(),
812 last_max_scroll: self.last_max_scroll,
813 auto_scroll_enabled: self.auto_scroll_enabled,
814 tool_index: self.tool_index.clone(),
815 spinner_index: self.spinner_index,
816 };
817 Box::new(state)
818 }
819
820 fn restore_state(&mut self, state: Box<dyn Any + Send>) {
821 if let Ok(chat_state) = state.downcast::<ChatViewState>() {
822 self.messages = chat_state.messages.into_iter().map(Message::from).collect();
823 self.scroll_offset = chat_state.scroll_offset;
824 self.streaming_buffer = chat_state.streaming_buffer;
825 self.last_max_scroll = chat_state.last_max_scroll;
826 self.auto_scroll_enabled = chat_state.auto_scroll_enabled;
827 self.tool_index = chat_state.tool_index;
828 self.spinner_index = chat_state.spinner_index;
829 }
830 }
831
832 fn clear(&mut self) {
833 self.messages.clear();
834 self.streaming_buffer = None;
835 self.tool_index.clear();
836 self.scroll_offset = 0;
837 self.last_max_scroll = 0;
838 self.auto_scroll_enabled = true;
839 self.spinner_index = 0;
840 }
842}
843
844use std::any::Any;
847use crossterm::event::KeyEvent;
848use super::{widget_ids, Widget, WidgetKeyContext, WidgetKeyResult};
849
850impl Widget for ChatView {
851 fn id(&self) -> &'static str {
852 widget_ids::CHAT_VIEW
853 }
854
855 fn priority(&self) -> u8 {
856 50 }
858
859 fn is_active(&self) -> bool {
860 true }
862
863 fn handle_key(&mut self, _key: KeyEvent, _ctx: &WidgetKeyContext) -> WidgetKeyResult {
864 WidgetKeyResult::NotHandled
867 }
868
869 fn render(&mut self, frame: &mut Frame, area: Rect, _theme: &Theme) {
870 self.render_chat(frame, area, None);
873 }
874
875 fn required_height(&self, _available: u16) -> u16 {
876 0 }
878
879 fn blocks_input(&self) -> bool {
880 false
881 }
882
883 fn is_overlay(&self) -> bool {
884 false
885 }
886
887 fn as_any(&self) -> &dyn Any {
888 self
889 }
890
891 fn as_any_mut(&mut self) -> &mut dyn Any {
892 self
893 }
894
895 fn into_any(self: Box<Self>) -> Box<dyn Any> {
896 self
897 }
898}
899