Skip to main content

graphrag_cli/ui/components/
info_panel.rs

1//! Info panel component with tabbed view: Stats | Sources | History
2
3use 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/// Query history entry
17#[derive(Debug, Clone)]
18pub struct QueryHistoryEntry {
19    pub query: String,
20    pub duration_ms: u128,
21    pub results_count: usize,
22}
23
24/// Info panel with three tabs: Stats, Sources, History
25pub struct InfoPanel {
26    /// Current graph statistics
27    stats: Option<GraphStats>,
28    /// Workspace name
29    workspace: Option<String>,
30    /// Query history (limited to last 10)
31    history: Vec<QueryHistoryEntry>,
32    /// Total queries executed
33    total_queries: usize,
34    /// Active tab: 0 = Stats, 1 = Sources, 2 = History
35    active_tab: usize,
36    /// Sources from the last explained query
37    sources: Vec<SourceRef>,
38    /// Confidence from the last explained query
39    confidence: Option<f32>,
40    /// Scroll offset within Sources or History tab
41    scroll_offset: usize,
42    /// Is this widget focused?
43    focused: bool,
44    /// Theme
45    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    /// Update sources from an explained query result and auto-switch to Sources tab
98    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; // auto-switch to Sources tab
102        self.scroll_offset = 0;
103    }
104
105    /// Cycle to next tab (wraps around)
106    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        // Split: tab bar (3 rows) + content (rest)
163        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        // Confidence bar at the top
291        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}