1use crate::{
4 action::{Action, QueryExplainedPayload, SourceRef},
5 handlers::graphrag::GraphStats,
6 theme::Theme,
7};
8use ratatui::{
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, List, ListItem, Paragraph, Tabs},
13 Frame,
14};
15
16#[derive(Debug, Clone)]
18pub struct QueryHistoryEntry {
19 pub query: String,
20 pub duration_ms: u128,
21 pub results_count: usize,
22}
23
24pub struct InfoPanel {
26 stats: Option<GraphStats>,
28 workspace: Option<String>,
30 history: Vec<QueryHistoryEntry>,
32 total_queries: usize,
34 active_tab: usize,
36 sources: Vec<SourceRef>,
38 confidence: Option<f32>,
40 scroll_offset: usize,
42 focused: bool,
44 theme: Theme,
46}
47
48impl InfoPanel {
49 pub fn new() -> Self {
50 Self {
51 stats: None,
52 workspace: None,
53 history: Vec::new(),
54 total_queries: 0,
55 active_tab: 0,
56 sources: Vec::new(),
57 confidence: None,
58 scroll_offset: 0,
59 focused: false,
60 theme: Theme::default(),
61 }
62 }
63
64 pub fn set_focused(&mut self, focused: bool) {
65 self.focused = focused;
66 }
67
68 #[allow(dead_code)]
69 pub fn is_focused(&self) -> bool {
70 self.focused
71 }
72
73 pub fn set_stats(&mut self, stats: GraphStats) {
74 self.stats = Some(stats);
75 }
76
77 #[allow(dead_code)]
78 pub fn set_workspace(&mut self, name: String) {
79 self.workspace = Some(name);
80 }
81
82 pub fn add_query(&mut self, query: String, duration_ms: u128, results_count: usize) {
83 self.history.insert(
84 0,
85 QueryHistoryEntry {
86 query,
87 duration_ms,
88 results_count,
89 },
90 );
91 if self.history.len() > 10 {
92 self.history.truncate(10);
93 }
94 self.total_queries += 1;
95 }
96
97 pub fn set_sources(&mut self, payload: &QueryExplainedPayload) {
99 self.sources = payload.sources.clone();
100 self.confidence = Some(payload.confidence);
101 self.active_tab = 1; self.scroll_offset = 0;
103 }
104
105 pub fn next_tab(&mut self) {
107 self.active_tab = (self.active_tab + 1) % 3;
108 self.scroll_offset = 0;
109 }
110
111 fn scroll_up(&mut self) {
112 self.scroll_offset = self.scroll_offset.saturating_sub(1);
113 }
114
115 fn scroll_down(&mut self, max: usize) {
116 if self.scroll_offset + 1 < max {
117 self.scroll_offset += 1;
118 }
119 }
120}
121
122impl super::Component for InfoPanel {
123 fn handle_action(&mut self, action: &Action) -> Option<Action> {
124 match action {
125 Action::RefreshStats => None,
126 Action::FocusInfoPanel => {
127 self.set_focused(true);
128 None
129 },
130 Action::QueryExplainedSuccess(payload) => {
131 self.set_sources(payload);
132 None
133 },
134 Action::NextTab => {
135 if self.focused {
136 self.next_tab();
137 }
138 None
139 },
140 Action::ScrollUp => {
141 if self.focused && self.active_tab != 0 {
142 self.scroll_up();
143 }
144 None
145 },
146 Action::ScrollDown => {
147 if self.focused && self.active_tab != 0 {
148 let max = match self.active_tab {
149 1 => self.sources.len(),
150 2 => self.history.len(),
151 _ => 0,
152 };
153 self.scroll_down(max);
154 }
155 None
156 },
157 _ => None,
158 }
159 }
160
161 fn render(&mut self, f: &mut Frame, area: Rect) {
162 let chunks = Layout::default()
164 .direction(Direction::Vertical)
165 .constraints([Constraint::Length(3), Constraint::Min(0)])
166 .split(area);
167
168 self.render_tab_bar(f, chunks[0]);
169
170 match self.active_tab {
171 0 => self.render_stats(f, chunks[1]),
172 1 => self.render_sources(f, chunks[1]),
173 2 => self.render_history(f, chunks[1]),
174 _ => {},
175 }
176 }
177}
178
179impl InfoPanel {
180 fn render_tab_bar(&self, f: &mut Frame, area: Rect) {
181 let border_style = if self.focused {
182 self.theme.border_focused()
183 } else {
184 self.theme.border()
185 };
186
187 let titles = vec!["Stats", "Sources", "History"];
188 let tabs = Tabs::new(titles)
189 .block(
190 Block::default()
191 .borders(Borders::ALL)
192 .border_style(border_style)
193 .title(if self.focused {
194 " Info Panel [ACTIVE] (Ctrl+N cycles tabs | Ctrl+P back) "
195 } else {
196 " Info Panel (Ctrl+4 or Ctrl+N to focus) "
197 }),
198 )
199 .select(self.active_tab)
200 .highlight_style(
201 Style::default()
202 .fg(Color::Cyan)
203 .add_modifier(ratatui::style::Modifier::BOLD),
204 )
205 .style(self.theme.dimmed());
206
207 f.render_widget(tabs, area);
208 }
209
210 fn render_stats(&self, f: &mut Frame, area: Rect) {
211 let block = Block::default()
212 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
213 .border_style(if self.focused {
214 self.theme.border_focused()
215 } else {
216 self.theme.border()
217 });
218
219 let content = if let Some(ref stats) = self.stats {
220 vec![
221 Line::from(""),
222 Line::from(vec![
223 Span::styled(" Entities: ", self.theme.dimmed()),
224 Span::styled(stats.entities.to_string(), self.theme.highlight()),
225 ]),
226 Line::from(vec![
227 Span::styled(" Relations: ", self.theme.dimmed()),
228 Span::styled(stats.relationships.to_string(), self.theme.highlight()),
229 ]),
230 Line::from(vec![
231 Span::styled(" Documents: ", self.theme.dimmed()),
232 Span::styled(stats.documents.to_string(), self.theme.highlight()),
233 ]),
234 Line::from(vec![
235 Span::styled(" Chunks: ", self.theme.dimmed()),
236 Span::styled(stats.chunks.to_string(), self.theme.highlight()),
237 ]),
238 Line::from(""),
239 Line::from(vec![
240 Span::styled(" Queries: ", self.theme.dimmed()),
241 Span::styled(self.total_queries.to_string(), self.theme.info()),
242 ]),
243 Line::from(vec![
244 Span::styled(" Workspace: ", self.theme.dimmed()),
245 Span::styled(
246 self.workspace.as_deref().unwrap_or("default").to_string(),
247 self.theme.info(),
248 ),
249 ]),
250 ]
251 } else {
252 vec![
253 Line::from(""),
254 Line::from(Span::styled(" No GraphRAG loaded.", self.theme.dimmed())),
255 Line::from(""),
256 Line::from(Span::styled(" Use /config <file>", self.theme.dimmed())),
257 Line::from(Span::styled(" to get started.", self.theme.dimmed())),
258 ]
259 };
260
261 let paragraph = Paragraph::new(content).block(block);
262 f.render_widget(paragraph, area);
263 }
264
265 fn render_sources(&self, f: &mut Frame, area: Rect) {
266 let block = Block::default()
267 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
268 .border_style(if self.focused {
269 self.theme.border_focused()
270 } else {
271 self.theme.border()
272 });
273
274 if self.sources.is_empty() {
275 let paragraph = Paragraph::new(vec![
276 Line::from(""),
277 Line::from(Span::styled(" No sources yet.", self.theme.dimmed())),
278 Line::from(""),
279 Line::from(Span::styled(
280 " Use /mode explain then",
281 self.theme.dimmed(),
282 )),
283 Line::from(Span::styled(" ask a question.", self.theme.dimmed())),
284 ])
285 .block(block);
286 f.render_widget(paragraph, area);
287 return;
288 }
289
290 let conf = self.confidence.unwrap_or(0.0);
292 let conf_color = if conf < 0.3 {
293 Color::Red
294 } else if conf < 0.7 {
295 Color::Yellow
296 } else {
297 Color::Green
298 };
299 let bar = confidence_bar(conf, 8);
300
301 let mut items: Vec<ListItem> = vec![
302 ListItem::new(Line::from(vec![
303 Span::styled(" Confidence: ", self.theme.dimmed()),
304 Span::styled(
305 format!("{:.0}% {}", conf * 100.0, bar),
306 Style::default().fg(conf_color),
307 ),
308 ])),
309 ListItem::new(Line::from(Span::styled(
310 format!(" Sources: {}", self.sources.len()),
311 self.theme.dimmed(),
312 ))),
313 ListItem::new(Line::from("")),
314 ];
315
316 for (i, src) in self.sources.iter().skip(self.scroll_offset).enumerate() {
317 let excerpt = if src.excerpt.len() > 60 {
318 format!("{}…", &src.excerpt[..57])
319 } else {
320 src.excerpt.clone()
321 };
322
323 items.push(ListItem::new(vec![
324 Line::from(vec![
325 Span::styled(
326 format!(" {}. ", i + 1 + self.scroll_offset),
327 self.theme.dimmed(),
328 ),
329 Span::styled(
330 format!("[{:.2}] ", src.relevance_score),
331 Style::default().fg(Color::Cyan),
332 ),
333 Span::styled(
334 src.id[..src.id.len().min(20)].to_string(),
335 self.theme.highlight(),
336 ),
337 ]),
338 Line::from(vec![
339 Span::raw(" ".to_owned()),
340 Span::styled(excerpt, self.theme.dimmed()),
341 ]),
342 Line::from(""),
343 ]));
344 }
345
346 let list = List::new(items).block(block);
347 f.render_widget(list, area);
348 }
349
350 fn render_history(&self, f: &mut Frame, area: Rect) {
351 let block = Block::default()
352 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
353 .border_style(if self.focused {
354 self.theme.border_focused()
355 } else {
356 self.theme.border()
357 });
358
359 if self.history.is_empty() {
360 let paragraph = Paragraph::new(vec![
361 Line::from(""),
362 Line::from(Span::styled(" No queries yet.", self.theme.dimmed())),
363 ])
364 .block(block);
365 f.render_widget(paragraph, area);
366 return;
367 }
368
369 let items: Vec<ListItem> = self
370 .history
371 .iter()
372 .skip(self.scroll_offset)
373 .enumerate()
374 .map(|(i, entry)| {
375 let query_display = if entry.query.len() > 28 {
376 format!("{}…", &entry.query[..25])
377 } else {
378 entry.query.clone()
379 };
380
381 ListItem::new(vec![
382 Line::from(vec![
383 Span::styled(
384 format!(" {}. ", i + 1 + self.scroll_offset),
385 self.theme.dimmed(),
386 ),
387 Span::styled(query_display, self.theme.text()),
388 ]),
389 Line::from(vec![
390 Span::raw(" ".to_owned()),
391 Span::styled(
392 format!("{}ms · {} src", entry.duration_ms, entry.results_count),
393 self.theme.dimmed(),
394 ),
395 ]),
396 Line::from(""),
397 ])
398 })
399 .collect();
400
401 let list = List::new(items).block(block).style(self.theme.text());
402 f.render_widget(list, area);
403 }
404}
405
406impl Default for InfoPanel {
407 fn default() -> Self {
408 Self::new()
409 }
410}
411
412fn confidence_bar(score: f32, width: usize) -> String {
413 let filled = (score * width as f32).round() as usize;
414 let empty = width.saturating_sub(filled);
415 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
416}