ratatui_toolkit/widgets/ai_chat/traits/
render.rs1use ratatui::{
2 layout::{Alignment, Constraint, Direction, Layout, Rect},
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, BorderType, Borders, List, ListItem, Paragraph, Wrap},
6 Frame,
7};
8
9use crate::widgets::ai_chat::state::MessageRole;
10use crate::widgets::ai_chat::AIChat;
11
12impl<'a> AIChat<'a> {
13 pub fn render(&self, frame: &mut Frame, area: Rect) {
14 let chunks = Layout::default()
15 .direction(Direction::Vertical)
16 .constraints([Constraint::Min(0), Constraint::Length(3)])
17 .split(area);
18
19 let messages_area = chunks[0];
20 let input_area = chunks[1];
21
22 self.render_messages(frame, messages_area);
23 self.render_input(frame, input_area);
24
25 if self.input.is_file_mode() {
26 self.render_file_popup(frame, input_area);
27 } else if self.input.is_command_mode() {
28 self.render_command_popup(frame, input_area);
29 }
30 }
31
32 fn render_messages(&self, frame: &mut Frame, area: Rect) {
33 let block = Block::default()
34 .borders(Borders::ALL)
35 .border_type(BorderType::Rounded)
36 .title(" Chat ");
37
38 let inner = block.inner(area);
39 frame.render_widget(block, area);
40
41 let mut items = Vec::new();
42
43 for msg in self.messages.messages() {
44 let prefix = match msg.role {
45 MessageRole::User => "You: ",
46 MessageRole::Assistant => "AI: ",
47 };
48
49 let style = match msg.role {
50 MessageRole::User => self.user_message_style,
51 MessageRole::Assistant => self.ai_message_style,
52 };
53
54 let mut content = vec![Span::styled(prefix, style)];
55
56 if !msg.attachments.is_empty() {
57 let files_str = msg
58 .attachments
59 .iter()
60 .map(|f| format!("@{}", f))
61 .collect::<Vec<_>>()
62 .join(", ");
63 content.push(Span::styled(
64 format!("[{}] ", files_str),
65 Style::default().fg(Color::Yellow),
66 ));
67 }
68
69 content.push(Span::raw(&msg.content));
70
71 let line = Line::from(content);
72 items.push(ListItem::new(line));
73 }
74
75 if self.is_loading {
76 items.push(ListItem::new(Line::from(vec![
77 Span::styled("AI: ", self.ai_message_style),
78 Span::styled("⠋ Thinking...", Style::default().fg(Color::Gray)),
79 ])));
80 }
81
82 let list = List::new(items)
83 .block(Block::default())
84 .style(Style::default());
85
86 frame.render_widget(list, inner);
87 }
88
89 fn render_input(&self, frame: &mut Frame, area: Rect) {
90 let mut input_text = self.input.text().to_string();
91
92 if self.input.is_file_mode() {
93 let filtered = self.input.filtered_files();
94 if let Some(file) = filtered.get(self.input.selected_file_index()) {
95 input_text = format!("@{}{}", self.input.file_query(), file);
96 } else {
97 input_text = format!("@{}", self.input.file_query());
98 }
99 } else if self.input.is_command_mode() {
100 let filtered = self.filtered_commands();
101 if let Some(cmd) = filtered.get(self.selected_command_index()) {
102 input_text = cmd.clone();
103 } else {
104 input_text = format!("/{}", self.input.command());
105 }
106 }
107
108 let prompt = &self.input_prompt;
109 let cursor_pos = prompt.len() + self.input.cursor();
110
111 let paragraph = Paragraph::new(format!("{}{}", prompt, input_text))
112 .style(self.input_style)
113 .block(Block::default());
114
115 frame.render_widget(paragraph, area);
116
117 if cursor_pos < input_text.len() + prompt.len() {
118 let cursor_x = area.x + cursor_pos as u16;
119 let cursor_y = area.y;
120 frame.set_cursor(cursor_x, cursor_y);
121 }
122 }
123
124 fn render_file_popup(&self, frame: &mut Frame, input_area: Rect) {
125 let filtered = self.input.filtered_files();
126
127 if filtered.is_empty() {
128 return;
129 }
130
131 let max_height = 10.min(filtered.len() as u16);
132 let popup_height = max_height + 2;
133
134 let popup_y = if input_area.y.saturating_sub(popup_height) > 0 {
135 input_area.y.saturating_sub(popup_height)
136 } else {
137 input_area.y.saturating_add(1)
138 };
139
140 let popup_width = 40.min(input_area.width);
141 let popup_x = input_area.x;
142
143 let popup_area = Rect {
144 x: popup_x,
145 y: popup_y,
146 width: popup_width,
147 height: popup_height,
148 };
149
150 let items: Vec<ListItem> = filtered
151 .iter()
152 .enumerate()
153 .map(|(i, file)| {
154 let style = if i == self.input.selected_file_index() {
155 Style::default()
156 .bg(Color::Blue)
157 .fg(Color::White)
158 .add_modifier(Modifier::BOLD)
159 } else {
160 Style::default().fg(Color::White).bg(Color::Black)
161 };
162 ListItem::new(Span::styled(file.clone(), style))
163 })
164 .collect();
165
166 let list = List::new(items).block(
167 Block::default()
168 .borders(Borders::ALL)
169 .border_type(BorderType::Rounded)
170 .style(Style::default().bg(Color::Black)),
171 );
172
173 frame.render_widget(list, popup_area);
174 }
175
176 fn render_command_popup(&self, frame: &mut Frame, input_area: Rect) {
177 let filtered = self.filtered_commands();
178
179 if filtered.is_empty() {
180 return;
181 }
182
183 let max_height = 10.min(filtered.len() as u16);
184 let popup_height = max_height + 2;
185
186 let popup_y = if input_area.y.saturating_sub(popup_height) > 0 {
187 input_area.y.saturating_sub(popup_height)
188 } else {
189 input_area.y.saturating_add(1)
190 };
191
192 let popup_width = 40.min(input_area.width);
193 let popup_x = input_area.x;
194
195 let popup_area = Rect {
196 x: popup_x,
197 y: popup_y,
198 width: popup_width,
199 height: popup_height,
200 };
201
202 let items: Vec<ListItem> = filtered
203 .iter()
204 .enumerate()
205 .map(|(i, cmd)| {
206 let style = if i == self.selected_command_index() {
207 Style::default()
208 .bg(Color::Blue)
209 .fg(Color::White)
210 .add_modifier(Modifier::BOLD)
211 } else {
212 Style::default().fg(Color::White).bg(Color::Black)
213 };
214 ListItem::new(Span::styled(cmd.clone(), style))
215 })
216 .collect();
217
218 let list = List::new(items).block(
219 Block::default()
220 .borders(Borders::ALL)
221 .border_type(BorderType::Rounded)
222 .style(Style::default().bg(Color::Black)),
223 );
224
225 frame.render_widget(list, popup_area);
226 }
227}