Skip to main content

envision/component/chat_view/
mod.rs

1//! A chat interface with message history and multi-line input.
2//!
3//! `ChatView` provides a scrollable message history display and a
4//! [`TextArea`](super::TextArea) input field. Messages can be typed
5//! as user, system, or assistant and are styled differently per role.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{
11//!     Component, Focusable, ChatView, ChatViewState,
12//!     ChatViewMessage, ChatViewOutput, ChatRole,
13//! };
14//!
15//! let mut state = ChatViewState::new();
16//! state.push_system("Welcome to the chat!");
17//! state.push_user("Hello");
18//! state.push_assistant("Hi! How can I help?");
19//!
20//! assert_eq!(state.message_count(), 3);
21//! assert_eq!(state.messages()[2].role(), ChatRole::Assistant);
22//! ```
23
24use 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/// The role of a chat message sender.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35#[cfg_attr(
36    feature = "serialization",
37    derive(serde::Serialize, serde::Deserialize)
38)]
39pub enum ChatRole {
40    /// A message from the user.
41    User,
42    /// A system message (announcements, status, etc.).
43    System,
44    /// A message from an assistant or bot.
45    Assistant,
46}
47
48impl ChatRole {
49    /// Returns the display prefix for this role.
50    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    /// Returns the display color for this role.
59    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/// A single chat message.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct ChatMessage {
71    /// The role of the sender.
72    role: ChatRole,
73    /// The message content.
74    content: String,
75    /// Optional timestamp.
76    timestamp: Option<String>,
77    /// Optional username override.
78    username: Option<String>,
79}
80
81impl ChatMessage {
82    /// Creates a new chat message.
83    ///
84    /// # Example
85    ///
86    /// ```rust
87    /// use envision::component::{ChatMessage, ChatRole};
88    ///
89    /// let msg = ChatMessage::new(ChatRole::User, "Hello!");
90    /// assert_eq!(msg.role(), ChatRole::User);
91    /// assert_eq!(msg.content(), "Hello!");
92    /// ```
93    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    /// Sets the timestamp (builder pattern).
103    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
104        self.timestamp = Some(timestamp.into());
105        self
106    }
107
108    /// Sets the username override (builder pattern).
109    pub fn with_username(mut self, username: impl Into<String>) -> Self {
110        self.username = Some(username.into());
111        self
112    }
113
114    /// Returns the role.
115    pub fn role(&self) -> ChatRole {
116        self.role
117    }
118
119    /// Returns the content.
120    pub fn content(&self) -> &str {
121        &self.content
122    }
123
124    /// Returns the timestamp.
125    pub fn timestamp(&self) -> Option<&str> {
126        self.timestamp.as_deref()
127    }
128
129    /// Returns the username (or the role's default prefix).
130    pub fn display_name(&self) -> &str {
131        self.username
132            .as_deref()
133            .unwrap_or_else(|| self.role.prefix())
134    }
135}
136
137/// Internal focus target for the chat view.
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139#[cfg_attr(
140    feature = "serialization",
141    derive(serde::Serialize, serde::Deserialize)
142)]
143enum Focus {
144    /// The message history is focused.
145    History,
146    /// The input field is focused.
147    #[default]
148    Input,
149}
150
151/// Messages that can be sent to a ChatView.
152#[derive(Clone, Debug, PartialEq, Eq)]
153pub enum ChatViewMessage {
154    /// Type a character in the input field.
155    Input(char),
156    /// Insert a newline in the input field.
157    NewLine,
158    /// Delete the character before the cursor.
159    Backspace,
160    /// Delete the character at the cursor.
161    Delete,
162    /// Move cursor left in the input field.
163    Left,
164    /// Move cursor right in the input field.
165    Right,
166    /// Move cursor up in the input field or scroll history.
167    Up,
168    /// Move cursor down in the input field or scroll history.
169    Down,
170    /// Move cursor to start of line.
171    Home,
172    /// Move cursor to end of line.
173    End,
174    /// Submit the current input as a user message.
175    Submit,
176    /// Toggle focus between history and input.
177    ToggleFocus,
178    /// Focus the input field.
179    FocusInput,
180    /// Focus the message history.
181    FocusHistory,
182    /// Scroll history up by one line.
183    ScrollUp,
184    /// Scroll history down by one line.
185    ScrollDown,
186    /// Scroll to the top of history.
187    ScrollToTop,
188    /// Scroll to the bottom of history (newest).
189    ScrollToBottom,
190    /// Clear the input field.
191    ClearInput,
192    /// Move cursor to the start of the input.
193    InputStart,
194    /// Move cursor to the end of the input.
195    InputEnd,
196    /// Delete from cursor to end of line.
197    DeleteToEnd,
198    /// Delete from line start to cursor.
199    DeleteToStart,
200}
201
202/// Output messages from a ChatView.
203#[derive(Clone, Debug, PartialEq, Eq)]
204pub enum ChatViewOutput {
205    /// The user submitted a message. Contains the message text.
206    Submitted(String),
207    /// The input text changed.
208    InputChanged(String),
209}
210
211/// State for a ChatView component.
212///
213/// Contains the message history, input field, and scroll state.
214#[derive(Clone, Debug)]
215pub struct ChatViewState {
216    /// Message history.
217    messages: Vec<ChatMessage>,
218    /// The text input area.
219    input: TextAreaState,
220    /// Scroll offset for the message history.
221    scroll_offset: usize,
222    /// Whether to auto-scroll to bottom on new messages.
223    auto_scroll: bool,
224    /// Maximum number of messages to keep.
225    max_messages: usize,
226    /// Internal focus target.
227    focus: Focus,
228    /// Whether the component is focused.
229    focused: bool,
230    /// Whether the component is disabled.
231    disabled: bool,
232    /// Whether to show timestamps.
233    show_timestamps: bool,
234    /// Input area height in lines.
235    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    /// Creates a new empty chat view state.
271    ///
272    /// # Example
273    ///
274    /// ```rust
275    /// use envision::component::ChatViewState;
276    ///
277    /// let state = ChatViewState::new();
278    /// assert_eq!(state.message_count(), 0);
279    /// assert!(state.input_value().is_empty());
280    /// ```
281    pub fn new() -> Self {
282        Self::default()
283    }
284
285    /// Sets the maximum number of messages (builder pattern).
286    pub fn with_max_messages(mut self, max: usize) -> Self {
287        self.max_messages = max;
288        self
289    }
290
291    /// Sets whether to show timestamps (builder pattern).
292    pub fn with_timestamps(mut self, show: bool) -> Self {
293        self.show_timestamps = show;
294        self
295    }
296
297    /// Sets the input area height (builder pattern).
298    pub fn with_input_height(mut self, height: u16) -> Self {
299        self.input_height = height.max(1);
300        self
301    }
302
303    /// Sets the disabled state (builder pattern).
304    pub fn with_disabled(mut self, disabled: bool) -> Self {
305        self.disabled = disabled;
306        self
307    }
308
309    /// Sets the input placeholder text (builder pattern).
310    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
311        self.input.set_placeholder(placeholder);
312        self
313    }
314
315    // ---- Message manipulation ----
316
317    /// Adds a message from any role.
318    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    /// Adds a user message.
329    ///
330    /// # Example
331    ///
332    /// ```rust
333    /// use envision::component::{ChatViewState, ChatRole};
334    ///
335    /// let mut state = ChatViewState::new();
336    /// state.push_user("Hello!");
337    /// assert_eq!(state.messages()[0].role(), ChatRole::User);
338    /// ```
339    pub fn push_user(&mut self, content: impl Into<String>) {
340        self.push_message(ChatMessage::new(ChatRole::User, content));
341    }
342
343    /// Adds a system message.
344    pub fn push_system(&mut self, content: impl Into<String>) {
345        self.push_message(ChatMessage::new(ChatRole::System, content));
346    }
347
348    /// Adds an assistant message.
349    pub fn push_assistant(&mut self, content: impl Into<String>) {
350        self.push_message(ChatMessage::new(ChatRole::Assistant, content));
351    }
352
353    /// Adds a user message with a timestamp.
354    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    /// Adds a system message with a timestamp.
363    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    /// Adds an assistant message with a timestamp.
372    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    /// Clears all messages.
381    pub fn clear_messages(&mut self) {
382        self.messages.clear();
383        self.scroll_offset = 0;
384    }
385
386    // ---- Accessors ----
387
388    /// Returns the messages.
389    pub fn messages(&self) -> &[ChatMessage] {
390        &self.messages
391    }
392
393    /// Returns the number of messages.
394    pub fn message_count(&self) -> usize {
395        self.messages.len()
396    }
397
398    /// Returns true if there are no messages.
399    pub fn is_empty(&self) -> bool {
400        self.messages.is_empty()
401    }
402
403    /// Returns the current input text.
404    pub fn input_value(&self) -> String {
405        self.input.value()
406    }
407
408    /// Sets the input text.
409    pub fn set_input_value(&mut self, value: impl Into<String>) {
410        self.input.set_value(value);
411    }
412
413    /// Returns the scroll offset.
414    pub fn scroll_offset(&self) -> usize {
415        self.scroll_offset
416    }
417
418    /// Returns the maximum number of messages.
419    pub fn max_messages(&self) -> usize {
420        self.max_messages
421    }
422
423    /// Sets the maximum number of messages.
424    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    /// Returns whether timestamps are shown.
432    pub fn show_timestamps(&self) -> bool {
433        self.show_timestamps
434    }
435
436    /// Sets whether timestamps are shown.
437    pub fn set_show_timestamps(&mut self, show: bool) {
438        self.show_timestamps = show;
439    }
440
441    /// Returns whether auto-scroll is enabled.
442    pub fn auto_scroll(&self) -> bool {
443        self.auto_scroll
444    }
445
446    /// Sets whether auto-scroll is enabled.
447    pub fn set_auto_scroll(&mut self, auto_scroll: bool) {
448        self.auto_scroll = auto_scroll;
449    }
450
451    /// Returns the input area height.
452    pub fn input_height(&self) -> u16 {
453        self.input_height
454    }
455
456    /// Sets the input area height.
457    pub fn set_input_height(&mut self, height: u16) {
458        self.input_height = height.max(1);
459    }
460
461    /// Returns whether the input field is focused.
462    pub fn is_input_focused(&self) -> bool {
463        self.focus == Focus::Input
464    }
465
466    /// Returns whether the history is focused.
467    pub fn is_history_focused(&self) -> bool {
468        self.focus == Focus::History
469    }
470
471    /// Scrolls the message history to the bottom (newest).
472    fn scroll_to_bottom(&mut self) {
473        self.scroll_offset = self.messages.len().saturating_sub(1);
474    }
475
476    // ---- Instance methods ----
477
478    /// Returns true if the component is focused.
479    pub fn is_focused(&self) -> bool {
480        self.focused
481    }
482
483    /// Sets the focus state.
484    pub fn set_focused(&mut self, focused: bool) {
485        self.focused = focused;
486    }
487
488    /// Returns true if the component is disabled.
489    pub fn is_disabled(&self) -> bool {
490        self.disabled
491    }
492
493    /// Sets the disabled state.
494    pub fn set_disabled(&mut self, disabled: bool) {
495        self.disabled = disabled;
496    }
497
498    /// Maps an input event to a chat view message.
499    pub fn handle_event(&self, event: &Event) -> Option<ChatViewMessage> {
500        ChatView::handle_event(self, event)
501    }
502
503    /// Dispatches an event, updating state and returning any output.
504    pub fn dispatch_event(&mut self, event: &Event) -> Option<ChatViewOutput> {
505        ChatView::dispatch_event(self, event)
506    }
507
508    /// Updates the state with a message, returning any output.
509    pub fn update(&mut self, msg: ChatViewMessage) -> Option<ChatViewOutput> {
510        ChatView::update(self, msg)
511    }
512}
513
514/// A chat interface with message history and multi-line input.
515///
516/// The input area uses a [`TextArea`](super::TextArea) for multi-line
517/// editing. Press Ctrl+Enter to submit a message, Tab to toggle between
518/// history scrolling and input editing.
519///
520/// # Key Bindings (Input Mode)
521///
522/// - Characters — Type text
523/// - `Enter` — New line in input
524/// - `Ctrl+Enter` — Submit message
525/// - `Tab` — Switch to history mode
526/// - `Backspace` / `Delete` — Edit text
527/// - `Left` / `Right` / `Home` / `End` — Cursor movement
528///
529/// # Key Bindings (History Mode)
530///
531/// - `Up` / `k` — Scroll history up
532/// - `Down` / `j` — Scroll history down
533/// - `Home` — Scroll to top (oldest)
534/// - `End` — Scroll to bottom (newest)
535/// - `Tab` — Switch to input mode
536pub 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        // Layout: history + input
742        let input_h = state.input_height + 2; // +2 for border
743        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
769/// Renders the message history area.
770fn 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    // Build display lines from messages
791    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    // Calculate scroll offset based on message-level scroll
801    // We'll show from offset based on display lines
802    let line_offset = if state.auto_scroll {
803        total_lines.saturating_sub(visible_lines)
804    } else {
805        // Approximate: each message is roughly 2+ lines
806        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
821/// Formats a chat message into display lines.
822fn 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    // Header line: [timestamp] Username:
833    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    // Content lines
849    for line in msg.content().lines() {
850        result.push((Line::from(Span::styled(format!("  {}", line), style)), role));
851    }
852
853    // Handle empty content
854    if msg.content().is_empty() {
855        result.push((Line::from(Span::styled("  ", style)), role));
856    }
857
858    result
859}
860
861/// Renders the input area.
862fn 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    // Show cursor when input is focused
904    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;