ratatui_toolkit/widgets/ai_chat/traits/
render.rs

1use 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}