1use std::marker::PhantomData;
25
26use ratatui::prelude::*;
27use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap};
28
29use super::{Component, Focusable, TextAreaMessage, TextAreaOutput, TextAreaState};
30use crate::input::{Event, KeyCode, KeyModifiers};
31use crate::theme::Theme;
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35#[cfg_attr(
36 feature = "serialization",
37 derive(serde::Serialize, serde::Deserialize)
38)]
39pub enum ChatRole {
40 User,
42 System,
44 Assistant,
46}
47
48impl ChatRole {
49 pub fn prefix(&self) -> &'static str {
51 match self {
52 Self::User => "You",
53 Self::System => "System",
54 Self::Assistant => "Assistant",
55 }
56 }
57
58 pub fn color(&self) -> Color {
60 match self {
61 Self::User => Color::Cyan,
62 Self::System => Color::DarkGray,
63 Self::Assistant => Color::Green,
64 }
65 }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct ChatMessage {
71 role: ChatRole,
73 content: String,
75 timestamp: Option<String>,
77 username: Option<String>,
79}
80
81impl ChatMessage {
82 pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
94 Self {
95 role,
96 content: content.into(),
97 timestamp: None,
98 username: None,
99 }
100 }
101
102 pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
104 self.timestamp = Some(timestamp.into());
105 self
106 }
107
108 pub fn with_username(mut self, username: impl Into<String>) -> Self {
110 self.username = Some(username.into());
111 self
112 }
113
114 pub fn role(&self) -> ChatRole {
116 self.role
117 }
118
119 pub fn content(&self) -> &str {
121 &self.content
122 }
123
124 pub fn timestamp(&self) -> Option<&str> {
126 self.timestamp.as_deref()
127 }
128
129 pub fn display_name(&self) -> &str {
131 self.username
132 .as_deref()
133 .unwrap_or_else(|| self.role.prefix())
134 }
135}
136
137#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139#[cfg_attr(
140 feature = "serialization",
141 derive(serde::Serialize, serde::Deserialize)
142)]
143enum Focus {
144 History,
146 #[default]
148 Input,
149}
150
151#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum ChatViewMessage {
154 Input(char),
156 NewLine,
158 Backspace,
160 Delete,
162 Left,
164 Right,
166 Up,
168 Down,
170 Home,
172 End,
174 Submit,
176 ToggleFocus,
178 FocusInput,
180 FocusHistory,
182 ScrollUp,
184 ScrollDown,
186 ScrollToTop,
188 ScrollToBottom,
190 ClearInput,
192 InputStart,
194 InputEnd,
196 DeleteToEnd,
198 DeleteToStart,
200}
201
202#[derive(Clone, Debug, PartialEq, Eq)]
204pub enum ChatViewOutput {
205 Submitted(String),
207 InputChanged(String),
209}
210
211#[derive(Clone, Debug)]
215pub struct ChatViewState {
216 messages: Vec<ChatMessage>,
218 input: TextAreaState,
220 scroll_offset: usize,
222 auto_scroll: bool,
224 max_messages: usize,
226 focus: Focus,
228 focused: bool,
230 disabled: bool,
232 show_timestamps: bool,
234 input_height: u16,
236}
237
238impl Default for ChatViewState {
239 fn default() -> Self {
240 Self {
241 messages: Vec::new(),
242 input: TextAreaState::with_placeholder("Type a message..."),
243 scroll_offset: 0,
244 auto_scroll: true,
245 max_messages: 1000,
246 focus: Focus::Input,
247 focused: false,
248 disabled: false,
249 show_timestamps: false,
250 input_height: 3,
251 }
252 }
253}
254
255impl PartialEq for ChatViewState {
256 fn eq(&self, other: &Self) -> bool {
257 self.messages == other.messages
258 && self.scroll_offset == other.scroll_offset
259 && self.auto_scroll == other.auto_scroll
260 && self.max_messages == other.max_messages
261 && self.focus == other.focus
262 && self.focused == other.focused
263 && self.disabled == other.disabled
264 && self.show_timestamps == other.show_timestamps
265 && self.input_height == other.input_height
266 }
267}
268
269impl ChatViewState {
270 pub fn new() -> Self {
282 Self::default()
283 }
284
285 pub fn with_max_messages(mut self, max: usize) -> Self {
287 self.max_messages = max;
288 self
289 }
290
291 pub fn with_timestamps(mut self, show: bool) -> Self {
293 self.show_timestamps = show;
294 self
295 }
296
297 pub fn with_input_height(mut self, height: u16) -> Self {
299 self.input_height = height.max(1);
300 self
301 }
302
303 pub fn with_disabled(mut self, disabled: bool) -> Self {
305 self.disabled = disabled;
306 self
307 }
308
309 pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
311 self.input.set_placeholder(placeholder);
312 self
313 }
314
315 pub fn push_message(&mut self, message: ChatMessage) {
319 self.messages.push(message);
320 while self.messages.len() > self.max_messages {
321 self.messages.remove(0);
322 }
323 if self.auto_scroll {
324 self.scroll_to_bottom();
325 }
326 }
327
328 pub fn push_user(&mut self, content: impl Into<String>) {
340 self.push_message(ChatMessage::new(ChatRole::User, content));
341 }
342
343 pub fn push_system(&mut self, content: impl Into<String>) {
345 self.push_message(ChatMessage::new(ChatRole::System, content));
346 }
347
348 pub fn push_assistant(&mut self, content: impl Into<String>) {
350 self.push_message(ChatMessage::new(ChatRole::Assistant, content));
351 }
352
353 pub fn push_user_with_timestamp(
355 &mut self,
356 content: impl Into<String>,
357 timestamp: impl Into<String>,
358 ) {
359 self.push_message(ChatMessage::new(ChatRole::User, content).with_timestamp(timestamp));
360 }
361
362 pub fn push_system_with_timestamp(
364 &mut self,
365 content: impl Into<String>,
366 timestamp: impl Into<String>,
367 ) {
368 self.push_message(ChatMessage::new(ChatRole::System, content).with_timestamp(timestamp));
369 }
370
371 pub fn push_assistant_with_timestamp(
373 &mut self,
374 content: impl Into<String>,
375 timestamp: impl Into<String>,
376 ) {
377 self.push_message(ChatMessage::new(ChatRole::Assistant, content).with_timestamp(timestamp));
378 }
379
380 pub fn clear_messages(&mut self) {
382 self.messages.clear();
383 self.scroll_offset = 0;
384 }
385
386 pub fn messages(&self) -> &[ChatMessage] {
390 &self.messages
391 }
392
393 pub fn message_count(&self) -> usize {
395 self.messages.len()
396 }
397
398 pub fn is_empty(&self) -> bool {
400 self.messages.is_empty()
401 }
402
403 pub fn input_value(&self) -> String {
405 self.input.value()
406 }
407
408 pub fn set_input_value(&mut self, value: impl Into<String>) {
410 self.input.set_value(value);
411 }
412
413 pub fn scroll_offset(&self) -> usize {
415 self.scroll_offset
416 }
417
418 pub fn max_messages(&self) -> usize {
420 self.max_messages
421 }
422
423 pub fn set_max_messages(&mut self, max: usize) {
425 self.max_messages = max;
426 while self.messages.len() > self.max_messages {
427 self.messages.remove(0);
428 }
429 }
430
431 pub fn show_timestamps(&self) -> bool {
433 self.show_timestamps
434 }
435
436 pub fn set_show_timestamps(&mut self, show: bool) {
438 self.show_timestamps = show;
439 }
440
441 pub fn auto_scroll(&self) -> bool {
443 self.auto_scroll
444 }
445
446 pub fn set_auto_scroll(&mut self, auto_scroll: bool) {
448 self.auto_scroll = auto_scroll;
449 }
450
451 pub fn input_height(&self) -> u16 {
453 self.input_height
454 }
455
456 pub fn set_input_height(&mut self, height: u16) {
458 self.input_height = height.max(1);
459 }
460
461 pub fn is_input_focused(&self) -> bool {
463 self.focus == Focus::Input
464 }
465
466 pub fn is_history_focused(&self) -> bool {
468 self.focus == Focus::History
469 }
470
471 fn scroll_to_bottom(&mut self) {
473 self.scroll_offset = self.messages.len().saturating_sub(1);
474 }
475
476 pub fn is_focused(&self) -> bool {
480 self.focused
481 }
482
483 pub fn set_focused(&mut self, focused: bool) {
485 self.focused = focused;
486 }
487
488 pub fn is_disabled(&self) -> bool {
490 self.disabled
491 }
492
493 pub fn set_disabled(&mut self, disabled: bool) {
495 self.disabled = disabled;
496 }
497
498 pub fn handle_event(&self, event: &Event) -> Option<ChatViewMessage> {
500 ChatView::handle_event(self, event)
501 }
502
503 pub fn dispatch_event(&mut self, event: &Event) -> Option<ChatViewOutput> {
505 ChatView::dispatch_event(self, event)
506 }
507
508 pub fn update(&mut self, msg: ChatViewMessage) -> Option<ChatViewOutput> {
510 ChatView::update(self, msg)
511 }
512}
513
514pub struct ChatView(PhantomData<()>);
537
538impl Component for ChatView {
539 type State = ChatViewState;
540 type Message = ChatViewMessage;
541 type Output = ChatViewOutput;
542
543 fn init() -> Self::State {
544 ChatViewState::default()
545 }
546
547 fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
548 if !state.focused || state.disabled {
549 return None;
550 }
551
552 let key = event.as_key()?;
553
554 match state.focus {
555 Focus::Input => {
556 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
557 match key.code {
558 KeyCode::Enter if ctrl => Some(ChatViewMessage::Submit),
559 KeyCode::Enter => Some(ChatViewMessage::NewLine),
560 KeyCode::Tab => Some(ChatViewMessage::ToggleFocus),
561 KeyCode::Char(c) if !ctrl => Some(ChatViewMessage::Input(c)),
562 KeyCode::Char('k') if ctrl => Some(ChatViewMessage::DeleteToEnd),
563 KeyCode::Char('u') if ctrl => Some(ChatViewMessage::DeleteToStart),
564 KeyCode::Backspace => Some(ChatViewMessage::Backspace),
565 KeyCode::Delete => Some(ChatViewMessage::Delete),
566 KeyCode::Left => Some(ChatViewMessage::Left),
567 KeyCode::Right => Some(ChatViewMessage::Right),
568 KeyCode::Up => Some(ChatViewMessage::Up),
569 KeyCode::Down => Some(ChatViewMessage::Down),
570 KeyCode::Home if ctrl => Some(ChatViewMessage::InputStart),
571 KeyCode::Home => Some(ChatViewMessage::Home),
572 KeyCode::End if ctrl => Some(ChatViewMessage::InputEnd),
573 KeyCode::End => Some(ChatViewMessage::End),
574 _ => None,
575 }
576 }
577 Focus::History => match key.code {
578 KeyCode::Up | KeyCode::Char('k') => Some(ChatViewMessage::ScrollUp),
579 KeyCode::Down | KeyCode::Char('j') => Some(ChatViewMessage::ScrollDown),
580 KeyCode::Home => Some(ChatViewMessage::ScrollToTop),
581 KeyCode::End => Some(ChatViewMessage::ScrollToBottom),
582 KeyCode::Tab => Some(ChatViewMessage::ToggleFocus),
583 _ => None,
584 },
585 }
586 }
587
588 fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
589 if state.disabled {
590 return None;
591 }
592
593 match msg {
594 ChatViewMessage::Input(c) => {
595 state.input.update(TextAreaMessage::Insert(c));
596 Some(ChatViewOutput::InputChanged(state.input.value()))
597 }
598 ChatViewMessage::NewLine => {
599 state.input.update(TextAreaMessage::NewLine);
600 Some(ChatViewOutput::InputChanged(state.input.value()))
601 }
602 ChatViewMessage::Backspace => {
603 if let Some(TextAreaOutput::Changed(_)) =
604 state.input.update(TextAreaMessage::Backspace)
605 {
606 Some(ChatViewOutput::InputChanged(state.input.value()))
607 } else {
608 None
609 }
610 }
611 ChatViewMessage::Delete => {
612 if let Some(TextAreaOutput::Changed(_)) =
613 state.input.update(TextAreaMessage::Delete)
614 {
615 Some(ChatViewOutput::InputChanged(state.input.value()))
616 } else {
617 None
618 }
619 }
620 ChatViewMessage::Left => {
621 state.input.update(TextAreaMessage::Left);
622 None
623 }
624 ChatViewMessage::Right => {
625 state.input.update(TextAreaMessage::Right);
626 None
627 }
628 ChatViewMessage::Up => {
629 state.input.update(TextAreaMessage::Up);
630 None
631 }
632 ChatViewMessage::Down => {
633 state.input.update(TextAreaMessage::Down);
634 None
635 }
636 ChatViewMessage::Home => {
637 state.input.update(TextAreaMessage::Home);
638 None
639 }
640 ChatViewMessage::End => {
641 state.input.update(TextAreaMessage::End);
642 None
643 }
644 ChatViewMessage::InputStart => {
645 state.input.update(TextAreaMessage::TextStart);
646 None
647 }
648 ChatViewMessage::InputEnd => {
649 state.input.update(TextAreaMessage::TextEnd);
650 None
651 }
652 ChatViewMessage::DeleteToEnd => {
653 if let Some(TextAreaOutput::Changed(_)) =
654 state.input.update(TextAreaMessage::DeleteToEnd)
655 {
656 Some(ChatViewOutput::InputChanged(state.input.value()))
657 } else {
658 None
659 }
660 }
661 ChatViewMessage::DeleteToStart => {
662 if let Some(TextAreaOutput::Changed(_)) =
663 state.input.update(TextAreaMessage::DeleteToStart)
664 {
665 Some(ChatViewOutput::InputChanged(state.input.value()))
666 } else {
667 None
668 }
669 }
670 ChatViewMessage::Submit => {
671 let value = state.input.value();
672 if value.trim().is_empty() {
673 return None;
674 }
675 state.push_user(&value);
676 state.input.update(TextAreaMessage::Clear);
677 Some(ChatViewOutput::Submitted(value))
678 }
679 ChatViewMessage::ToggleFocus => {
680 match state.focus {
681 Focus::Input => {
682 state.focus = Focus::History;
683 state.input.set_focused(false);
684 }
685 Focus::History => {
686 state.focus = Focus::Input;
687 state.input.set_focused(true);
688 }
689 }
690 None
691 }
692 ChatViewMessage::FocusInput => {
693 state.focus = Focus::Input;
694 state.input.set_focused(true);
695 None
696 }
697 ChatViewMessage::FocusHistory => {
698 state.focus = Focus::History;
699 state.input.set_focused(false);
700 None
701 }
702 ChatViewMessage::ScrollUp => {
703 if state.scroll_offset > 0 {
704 state.scroll_offset -= 1;
705 state.auto_scroll = false;
706 }
707 None
708 }
709 ChatViewMessage::ScrollDown => {
710 let max = state.messages.len().saturating_sub(1);
711 if state.scroll_offset < max {
712 state.scroll_offset += 1;
713 if state.scroll_offset >= max {
714 state.auto_scroll = true;
715 }
716 }
717 None
718 }
719 ChatViewMessage::ScrollToTop => {
720 state.scroll_offset = 0;
721 state.auto_scroll = false;
722 None
723 }
724 ChatViewMessage::ScrollToBottom => {
725 state.scroll_to_bottom();
726 state.auto_scroll = true;
727 None
728 }
729 ChatViewMessage::ClearInput => {
730 state.input.update(TextAreaMessage::Clear);
731 Some(ChatViewOutput::InputChanged(String::new()))
732 }
733 }
734 }
735
736 fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
737 if area.height < 4 {
738 return;
739 }
740
741 let input_h = state.input_height + 2; let chunks = Layout::default()
744 .direction(Direction::Vertical)
745 .constraints([Constraint::Min(1), Constraint::Length(input_h)])
746 .split(area);
747
748 let history_area = chunks[0];
749 let input_area = chunks[1];
750
751 render_history(state, frame, history_area, theme);
752 render_input(state, frame, input_area, theme);
753 }
754}
755
756impl Focusable for ChatView {
757 fn is_focused(state: &Self::State) -> bool {
758 state.focused
759 }
760
761 fn set_focused(state: &mut Self::State, focused: bool) {
762 state.focused = focused;
763 if focused && state.focus == Focus::Input {
764 state.input.set_focused(true);
765 }
766 }
767}
768
769fn render_history(state: &ChatViewState, frame: &mut Frame, area: Rect, theme: &Theme) {
771 let border_style = if state.disabled {
772 theme.disabled_style()
773 } else if state.focused && state.focus == Focus::History {
774 theme.focused_border_style()
775 } else {
776 theme.border_style()
777 };
778
779 let block = Block::default()
780 .borders(Borders::ALL)
781 .border_style(border_style);
782
783 let inner = block.inner(area);
784 frame.render_widget(block, area);
785
786 if inner.height == 0 || inner.width == 0 {
787 return;
788 }
789
790 let display_lines: Vec<(Line, ChatRole)> = state
792 .messages
793 .iter()
794 .flat_map(|msg| format_message(msg, state.show_timestamps, inner.width as usize))
795 .collect();
796
797 let total_lines = display_lines.len();
798 let visible_lines = inner.height as usize;
799
800 let line_offset = if state.auto_scroll {
803 total_lines.saturating_sub(visible_lines)
804 } else {
805 let estimated_line = state.scroll_offset.saturating_mul(2);
807 estimated_line.min(total_lines.saturating_sub(visible_lines))
808 };
809
810 let items: Vec<ListItem> = display_lines
811 .into_iter()
812 .skip(line_offset)
813 .take(visible_lines)
814 .map(|(line, _)| ListItem::new(line))
815 .collect();
816
817 let list = List::new(items);
818 frame.render_widget(list, inner);
819}
820
821fn format_message(
823 msg: &ChatMessage,
824 show_timestamps: bool,
825 _width: usize,
826) -> Vec<(Line<'_>, ChatRole)> {
827 let mut result = Vec::new();
828 let role = msg.role();
829 let style = Style::default().fg(role.color());
830 let bold_style = style.add_modifier(Modifier::BOLD);
831
832 let mut header_spans = Vec::new();
834
835 if show_timestamps {
836 if let Some(ts) = msg.timestamp() {
837 header_spans.push(Span::styled(
838 format!("[{}] ", ts),
839 Style::default().fg(Color::DarkGray),
840 ));
841 }
842 }
843
844 header_spans.push(Span::styled(format!("{}:", msg.display_name()), bold_style));
845
846 result.push((Line::from(header_spans), role));
847
848 for line in msg.content().lines() {
850 result.push((Line::from(Span::styled(format!(" {}", line), style)), role));
851 }
852
853 if msg.content().is_empty() {
855 result.push((Line::from(Span::styled(" ", style)), role));
856 }
857
858 result
859}
860
861fn render_input(state: &ChatViewState, frame: &mut Frame, area: Rect, theme: &Theme) {
863 let border_style = if state.disabled {
864 theme.disabled_style()
865 } else if state.focused && state.focus == Focus::Input {
866 theme.focused_border_style()
867 } else {
868 theme.border_style()
869 };
870
871 let text_style = if state.disabled {
872 theme.disabled_style()
873 } else if state.focused && state.focus == Focus::Input {
874 theme.focused_style()
875 } else {
876 theme.normal_style()
877 };
878
879 let value = state.input.value();
880 let display_text = if value.is_empty() && !state.input.placeholder().is_empty() {
881 state.input.placeholder().to_string()
882 } else {
883 value
884 };
885
886 let text_style_final = if state.input.is_empty() && !state.input.placeholder().is_empty() {
887 theme.placeholder_style()
888 } else {
889 text_style
890 };
891
892 let paragraph = Paragraph::new(display_text)
893 .style(text_style_final)
894 .block(
895 Block::default()
896 .borders(Borders::ALL)
897 .border_style(border_style),
898 )
899 .wrap(Wrap { trim: false });
900
901 frame.render_widget(paragraph, area);
902
903 if state.focused && state.focus == Focus::Input && !state.disabled {
905 let (cursor_row, cursor_col) = state.input.cursor_display_position();
906 let cursor_x = area.x + 1 + cursor_col as u16;
907 let cursor_y = area.y + 1 + cursor_row as u16;
908 if cursor_x < area.right() - 1 && cursor_y < area.bottom() - 1 {
909 frame.set_cursor_position(Position::new(cursor_x, cursor_y));
910 }
911 }
912}
913
914#[cfg(test)]
915mod tests;