Skip to main content

oxi_tui/widget/chat/
mod.rs

1//! Chat widgets: per-message item, tool-call card, animated spinner, and the
2//! scrollable [`ChatView`].
3
4pub mod message_item;
5pub mod spinner;
6pub mod tool_call;
7
8use ratatui::layout::Rect;
9
10use crate::content::{ChatLog, ChatViewState};
11use crate::widget::{RenderCtx, Renderable, RetainedChild, hash_combine};
12
13pub use message_item::MessageItem;
14pub use spinner::Spinner;
15pub use tool_call::ToolCall;
16
17/// A virtualized, retained chat transcript.
18///
19/// Messages are kept in stable [`RetainedChild`] slots. During streaming the
20/// active message's hash changes, while unchanged siblings retain their last
21/// rendered hash and are skipped.
22#[derive(Debug, Default)]
23pub struct ChatView {
24    log: ChatLog,
25    view: ChatViewState,
26    items: Vec<RetainedChild<message_item::MessageItem>>,
27}
28
29impl ChatView {
30    /// Creates an empty chat view.
31    #[must_use]
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Returns the conversation log.
37    #[must_use]
38    pub const fn log(&self) -> &ChatLog {
39        &self.log
40    }
41
42    /// Returns the conversation log for mutation.
43    pub const fn log_mut(&mut self) -> &mut ChatLog {
44        &mut self.log
45    }
46
47    /// Returns the viewport state.
48    #[must_use]
49    pub const fn view(&self) -> &ChatViewState {
50        &self.view
51    }
52
53    /// Returns the viewport state for mutation.
54    pub const fn view_mut(&mut self) -> &mut ChatViewState {
55        &mut self.view
56    }
57
58    /// Synchronizes retained message widgets with newly appended messages.
59    fn sync_items(&mut self) {
60        let messages = self.log.messages();
61        while self.items.len() < messages.len() {
62            let index = self.items.len();
63            self.items
64                .push(RetainedChild::new(message_item::MessageItem::new(
65                    messages[index].clone(),
66                )));
67        }
68    }
69}
70
71impl Renderable for ChatView {
72    fn content_hash(&self) -> u64 {
73        // The width is a default because the retained-tree pass has no area.
74        // The render pass uses the actual width when laying out each child.
75        hash_combine(
76            self.log.content_hash(),
77            self.view.viewport_hash(&self.log, 80),
78        )
79    }
80
81    fn height_for(&self, _width: u16, _ctx: &RenderCtx) -> u16 {
82        let (start, end) = self.view.visible_msg_range(&self.log, 24);
83        u16::try_from(end.saturating_sub(start)).unwrap_or(u16::MAX)
84    }
85
86    fn render(&mut self, area: Rect, ctx: &mut RenderCtx) {
87        self.view.set_viewport_height(area.height);
88        self.sync_items();
89        let (start, end) = self.view.visible_msg_range(&self.log, area.height);
90        let mut y = area.y;
91        for index in start..end {
92            if index >= self.items.len() {
93                break;
94            }
95            self.items[index]
96                .inner_mut()
97                .sync_from(&self.log.messages()[index]);
98            let height = self.items[index].inner().height_for(area.width, ctx);
99            let remaining = area.y.saturating_add(area.height).saturating_sub(y);
100            let item_area = Rect {
101                x: area.x,
102                y,
103                width: area.width,
104                height: height.min(remaining),
105            };
106            self.items[index].render_if_changed(item_area, ctx);
107            y = y.saturating_add(height);
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::content::MessageRole;
116    use crate::theme::{TerminalCaps, Theme};
117    use ratatui::Terminal;
118    use ratatui::backend::TestBackend;
119
120    fn render(view: &mut ChatView, width: u16, height: u16) {
121        let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
122        terminal
123            .draw(|frame| {
124                let theme = Theme::dark();
125                let caps = TerminalCaps::default();
126                let mut ctx = RenderCtx::new(frame, &theme, &caps);
127                view.render(ctx.area(), &mut ctx);
128            })
129            .unwrap();
130    }
131
132    #[test]
133    fn streaming_token_re_renders_only_active_message() {
134        let mut view = ChatView::new();
135        let _ = view.log_mut().append_message(MessageRole::User);
136        let _ = view.log_mut().append_message(MessageRole::User);
137        let _ = view.log_mut().append_message(MessageRole::Assistant);
138        view.log_mut().append_token("token");
139        render(&mut view, 80, 24);
140        let before: Vec<usize> = view
141            .items
142            .iter()
143            .map(|item| item.inner().render_count())
144            .collect();
145        view.log_mut().append_token(" more");
146        render(&mut view, 80, 24);
147        let after: Vec<usize> = view
148            .items
149            .iter()
150            .map(|item| item.inner().render_count())
151            .collect();
152        assert_eq!(after[0], before[0]);
153        assert_eq!(after[1], before[1]);
154        assert_eq!(after[2], before[2] + 1);
155    }
156
157    #[test]
158    fn renders_visible_messages_only() {
159        let mut view = ChatView::new();
160        for _ in 0..5 {
161            let _ = view.log_mut().append_message(MessageRole::User);
162        }
163        render(&mut view, 80, 2);
164        assert_eq!(
165            view.items
166                .iter()
167                .filter(|item| item.inner().render_count() > 0)
168                .count(),
169            2
170        );
171    }
172
173    #[test]
174    fn scroll_skips_offscreen_messages() {
175        let mut view = ChatView::new();
176        for _ in 0..5 {
177            let _ = view.log_mut().append_message(MessageRole::User);
178        }
179        view.view_mut().scroll_up(2);
180        render(&mut view, 80, 2);
181        let rendered = view
182            .items
183            .iter()
184            .enumerate()
185            .filter_map(|(i, item)| (item.inner().render_count() > 0).then_some(i))
186            .collect::<Vec<_>>();
187        assert_eq!(rendered, vec![1, 2]);
188    }
189
190    #[test]
191    fn new_message_appended_correctly() {
192        let mut view = ChatView::new();
193        let _ = view.log_mut().append_message(MessageRole::User);
194        render(&mut view, 80, 2);
195        let _ = view.log_mut().append_message(MessageRole::Assistant);
196        assert_eq!(view.items.len(), 1);
197        render(&mut view, 80, 2);
198        assert_eq!(view.items.len(), 2);
199    }
200}