oxi_tui/widget/chat/
mod.rs1pub 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#[derive(Debug, Default)]
23pub struct ChatView {
24 log: ChatLog,
25 view: ChatViewState,
26 items: Vec<RetainedChild<message_item::MessageItem>>,
27}
28
29impl ChatView {
30 #[must_use]
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 #[must_use]
38 pub const fn log(&self) -> &ChatLog {
39 &self.log
40 }
41
42 pub const fn log_mut(&mut self) -> &mut ChatLog {
44 &mut self.log
45 }
46
47 #[must_use]
49 pub const fn view(&self) -> &ChatViewState {
50 &self.view
51 }
52
53 pub const fn view_mut(&mut self) -> &mut ChatViewState {
55 &mut self.view
56 }
57
58 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 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}