graphrag_cli/ui/components/
results_viewer.rs1use crate::{action::Action, theme::Theme};
4use ratatui::{
5 layout::{Margin, Rect},
6 text::{Line, Text},
7 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
8 Frame,
9};
10
11pub struct ResultsViewer {
13 content_lines: Vec<Line<'static>>,
15 scroll_offset: usize,
17 scrollbar_state: ScrollbarState,
19 focused: bool,
21 theme: Theme,
23}
24
25impl ResultsViewer {
26 pub fn new() -> Self {
27 let welcome = crate::ui::markdown::parse_markdown(
28 "# Welcome to GraphRAG CLI\n\
29 \n\
30 To get started:\n\
31 \n\
32 - Load a config: `/config path/to/config.json5`\n\
33 - Load a document: `/load path/to/document.txt`\n\
34 - Ask questions in natural language!\n\
35 \n\
36 Press **?** for help | Use `/mode explain` for richer output",
37 );
38 let mut rv = Self {
39 content_lines: welcome,
40 scroll_offset: 0,
41 scrollbar_state: ScrollbarState::default(),
42 focused: false,
43 theme: Theme::default(),
44 };
45 rv.update_scrollbar();
46 rv
47 }
48
49 pub fn set_content(&mut self, lines: Vec<String>) {
51 let combined = lines.join("\n");
52 self.content_lines = crate::ui::markdown::parse_markdown(&combined);
53 self.scroll_offset = 0;
54 self.update_scrollbar();
55 }
56
57 pub fn set_lines(&mut self, lines: Vec<Line<'static>>) {
59 self.content_lines = lines;
60 self.scroll_offset = 0;
61 self.update_scrollbar();
62 }
63
64 #[allow(dead_code)]
66 pub fn append_content(&mut self, lines: Vec<String>) {
67 let combined = lines.join("\n");
68 let mut new_lines = crate::ui::markdown::parse_markdown(&combined);
69 self.content_lines.append(&mut new_lines);
70 self.update_scrollbar();
71 }
72
73 #[allow(dead_code)]
75 pub fn clear(&mut self) {
76 self.content_lines.clear();
77 self.scroll_offset = 0;
78 self.update_scrollbar();
79 }
80
81 pub fn scroll_up(&mut self) {
82 self.scroll_offset = self.scroll_offset.saturating_sub(1);
83 self.update_scrollbar();
84 }
85
86 pub fn scroll_down(&mut self) {
87 if self.scroll_offset < self.content_lines.len().saturating_sub(1) {
88 self.scroll_offset += 1;
89 }
90 self.update_scrollbar();
91 }
92
93 pub fn scroll_page_up(&mut self, page_size: usize) {
94 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
95 self.update_scrollbar();
96 }
97
98 pub fn scroll_page_down(&mut self, page_size: usize) {
99 let max_scroll = self.content_lines.len().saturating_sub(1);
100 self.scroll_offset = (self.scroll_offset + page_size).min(max_scroll);
101 self.update_scrollbar();
102 }
103
104 pub fn scroll_to_top(&mut self) {
105 self.scroll_offset = 0;
106 self.update_scrollbar();
107 }
108
109 pub fn scroll_to_bottom(&mut self) {
110 self.scroll_offset = self.content_lines.len().saturating_sub(1);
111 self.update_scrollbar();
112 }
113
114 fn update_scrollbar(&mut self) {
115 self.scrollbar_state = self
116 .scrollbar_state
117 .content_length(self.content_lines.len())
118 .position(self.scroll_offset);
119 }
120
121 pub fn set_focused(&mut self, focused: bool) {
122 self.focused = focused;
123 }
124}
125
126impl super::Component for ResultsViewer {
127 fn handle_action(&mut self, action: &Action) -> Option<Action> {
128 match action {
129 Action::ScrollUp => {
130 if self.focused {
131 self.scroll_up();
132 }
133 None
134 },
135 Action::ScrollDown => {
136 if self.focused {
137 self.scroll_down();
138 }
139 None
140 },
141 Action::ScrollPageUp => {
142 if self.focused {
143 self.scroll_page_up(10);
144 }
145 None
146 },
147 Action::ScrollPageDown => {
148 if self.focused {
149 self.scroll_page_down(10);
150 }
151 None
152 },
153 Action::ScrollToTop => {
154 if self.focused {
155 self.scroll_to_top();
156 }
157 None
158 },
159 Action::ScrollToBottom => {
160 if self.focused {
161 self.scroll_to_bottom();
162 }
163 None
164 },
165 Action::FocusResultsViewer => {
166 self.set_focused(true);
167 None
168 },
169 Action::QuerySuccess(result) => {
170 self.set_content(vec![
171 "## Query Result".to_string(),
172 String::new(),
173 result.clone(),
174 ]);
175 None
176 },
177 Action::QueryExplainedSuccess(payload) => {
178 use ratatui::{
179 style::{Color, Style},
180 text::Span,
181 };
182 let conf_color = confidence_color(payload.confidence);
183 let conf_bar = confidence_bar(payload.confidence, 10);
184 let header_line = Line::from(vec![
185 Span::styled("Query Result ".to_owned(), self.theme.title()),
186 Span::styled(
187 format!(
188 "[EXPLAIN | {:.0}% {}]",
189 payload.confidence * 100.0,
190 conf_bar
191 ),
192 Style::default().fg(conf_color),
193 ),
194 ]);
195 let mut lines: Vec<Line<'static>> = vec![
196 header_line,
197 Line::from(Span::styled(
198 "━".repeat(50),
199 Style::default().fg(Color::DarkGray),
200 )),
201 Line::from(""),
202 ];
203 lines.extend(crate::ui::markdown::parse_markdown(&payload.answer));
204 self.set_lines(lines);
205 None
206 },
207 Action::QueryError(error) => {
208 self.set_content(vec![
209 "## Query Error".to_string(),
210 String::new(),
211 format!("> {}", error),
212 ]);
213 None
214 },
215 _ => None,
216 }
217 }
218
219 fn render(&mut self, f: &mut Frame, area: Rect) {
220 let border_style = if self.focused {
221 self.theme.border_focused()
222 } else {
223 self.theme.border()
224 };
225
226 let title = if self.focused {
227 " Results Viewer [ACTIVE] (j/k or ↑↓ to scroll | Ctrl+N next panel) "
228 } else {
229 " Results Viewer (Ctrl+2 or Ctrl+N to focus) "
230 };
231
232 let block = Block::default()
233 .title(title)
234 .borders(Borders::ALL)
235 .border_style(border_style);
236
237 let visible: Vec<Line> = self
238 .content_lines
239 .iter()
240 .skip(self.scroll_offset)
241 .cloned()
242 .collect();
243
244 let paragraph = Paragraph::new(Text::from(visible))
245 .block(block)
246 .wrap(Wrap { trim: false })
247 .style(self.theme.text());
248
249 f.render_widget(paragraph, area);
250
251 if self.content_lines.len() > area.height as usize {
252 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
253 .begin_symbol(Some("↑"))
254 .end_symbol(Some("↓"));
255
256 let scrollbar_area = area.inner(Margin {
257 vertical: 1,
258 horizontal: 0,
259 });
260
261 f.render_stateful_widget(scrollbar, scrollbar_area, &mut self.scrollbar_state);
262 }
263 }
264}
265
266impl Default for ResultsViewer {
267 fn default() -> Self {
268 Self::new()
269 }
270}
271
272fn confidence_color(score: f32) -> ratatui::style::Color {
273 use ratatui::style::Color;
274 if score < 0.3 {
275 Color::Red
276 } else if score < 0.7 {
277 Color::Yellow
278 } else {
279 Color::Green
280 }
281}
282
283fn confidence_bar(score: f32, width: usize) -> String {
284 let filled = (score * width as f32).round() as usize;
285 let empty = width.saturating_sub(filled);
286 format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
287}