Skip to main content

kimun_notes/components/
rag_answer.rs

1//! The RAG answer overlay (P4 2b): a modal that asks the RAG server a question
2//! and shows the LLM answer plus its cited source notes (each openable). Opened
3//! by a keybinding; only useful when a server is configured + reachable.
4
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use kimun_core::NoteVault;
9use kimun_core::nfs::VaultPath;
10use ratatui::Frame;
11use ratatui::crossterm::event::{KeyCode, KeyEvent};
12use ratatui::layout::{Constraint, Direction, Layout, Rect};
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
16
17use crate::components::event_state::EventState;
18use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData};
19use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
20use crate::components::single_line_input::{InputOutcome, SingleLineInput};
21use crate::rag::{RagAnswer, RagSource, rag_client};
22use crate::settings::SharedSettings;
23use crate::settings::themes::Theme;
24
25enum State {
26    /// Typing the question.
27    Prompt,
28    /// Waiting on the server.
29    Loading,
30    /// Answer received.
31    Answered(RagAnswer),
32    /// The ask failed.
33    Error(String),
34}
35
36/// Process-global ask counter, so request ids are unique across overlay
37/// instances (a stale answer from a closed overlay never matches a new one).
38static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
39
40pub struct RagAnswerOverlay {
41    vault: Arc<NoteVault>,
42    settings: SharedSettings,
43    prompt: SingleLineInput,
44    state: State,
45    /// Selected source row (in the Answered state).
46    selected: usize,
47    /// Id of the in-flight ask; results with a different id are ignored. `0`
48    /// (never issued) before the first ask.
49    request_id: u64,
50}
51
52impl RagAnswerOverlay {
53    pub fn new(vault: Arc<NoteVault>, settings: SharedSettings) -> Self {
54        Self {
55            vault,
56            settings,
57            prompt: SingleLineInput::new(),
58            state: State::Prompt,
59            selected: 0,
60            request_id: 0,
61        }
62    }
63
64    /// Spawns the ask job; the result comes back via `AppEvent::OverlayData(OverlayData::RagAnswerReady)`.
65    fn submit(&mut self, tx: &AppTx) {
66        let query = self.prompt.value().trim().to_string();
67        if query.is_empty() {
68            return;
69        }
70        self.state = State::Loading;
71        let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
72        self.request_id = request_id;
73        let vault = self.vault.clone();
74        let settings = self.settings.clone();
75        let tx = tx.clone();
76        tokio::spawn(async move {
77            let result = match rag_client(&settings, &vault).await {
78                Some(client) => client
79                    .ask(&query, None)
80                    .await
81                    .map(|answer| RagAnswer {
82                        answer: answer.answer,
83                        sources: answer
84                            .sources
85                            .into_iter()
86                            .map(|s| RagSource {
87                                path: VaultPath::new(&s.path),
88                                title: s.title,
89                            })
90                            .collect(),
91                    })
92                    .map_err(|e| e.to_string()),
93                None => Err("No RAG server configured".to_string()),
94            };
95            let _ = tx.send(AppEvent::OverlayData(OverlayData::RagAnswerReady {
96                request_id,
97                result,
98            }));
99        });
100    }
101
102    fn sources(&self) -> &[RagSource] {
103        match &self.state {
104            State::Answered(a) => &a.sources,
105            _ => &[],
106        }
107    }
108
109    fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
110        match key.code {
111            KeyCode::Esc => {
112                tx.send(AppEvent::CloseOverlay).ok();
113                EventState::Consumed
114            }
115            _ => match self.state {
116                State::Prompt => self.handle_prompt_key(key, tx),
117                State::Answered(_) => self.handle_answered_key(key, tx),
118                // Loading / Error: only Esc (above) does anything.
119                _ => EventState::Consumed,
120            },
121        }
122    }
123
124    fn handle_prompt_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
125        // Esc (Cancel) is handled by the overlay-level arm before we get here.
126        if self.prompt.handle_key(key) == InputOutcome::Submit {
127            self.submit(tx);
128        }
129        EventState::Consumed
130    }
131
132    fn handle_answered_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
133        let count = self.sources().len();
134        match key.code {
135            KeyCode::Up => self.selected = self.selected.saturating_sub(1),
136            KeyCode::Down => {
137                if count > 0 {
138                    self.selected = (self.selected + 1).min(count - 1);
139                }
140            }
141            KeyCode::Enter => {
142                if let Some(src) = self.sources().get(self.selected) {
143                    tx.send(AppEvent::open(src.path.clone())).ok();
144                    tx.send(AppEvent::CloseOverlay).ok();
145                }
146            }
147            _ => {}
148        }
149        EventState::Consumed
150    }
151}
152
153impl Overlay for RagAnswerOverlay {
154    fn kind(&self) -> OverlayKind {
155        OverlayKind::RagAnswer
156    }
157
158    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
159        match event {
160            InputEvent::Key(key) => self.handle_key(key, tx),
161            _ => EventState::Consumed, // modal: swallow mouse/other so it stays put
162        }
163    }
164
165    fn handle_data(
166        &mut self,
167        data: &OverlayData,
168        _vault: &Arc<NoteVault>,
169        _tx: &AppTx,
170    ) -> OverlayMsg {
171        if let OverlayData::RagAnswerReady { request_id, result } = data {
172            // Ignore a late answer from a superseded / closed ask.
173            if *request_id != self.request_id {
174                return OverlayMsg::NotConsumed;
175            }
176            self.selected = 0;
177            self.state = match result {
178                Ok(answer) => State::Answered(answer.clone()),
179                Err(e) => State::Error(e.clone()),
180            };
181            OverlayMsg::Consumed
182        } else {
183            OverlayMsg::NotConsumed
184        }
185    }
186
187    fn hint_shortcuts(&self) -> Vec<(String, String)> {
188        match self.state {
189            State::Prompt => vec![
190                ("Enter".into(), "Ask".into()),
191                ("Esc".into(), "Close".into()),
192            ],
193            State::Answered(_) => vec![
194                ("↑↓".into(), "Sources".into()),
195                ("Enter".into(), "Open".into()),
196                ("Esc".into(), "Close".into()),
197            ],
198            _ => vec![("Esc".into(), "Close".into())],
199        }
200    }
201
202    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
203        let modal = crate::components::centered_rect(80, 70, area);
204        f.render_widget(Clear, modal);
205        let block = Block::default()
206            .borders(Borders::ALL)
207            .title(" Ask (RAG) ")
208            .border_style(Style::default().fg(theme.accent.to_ratatui()));
209        let inner = block.inner(modal);
210        f.render_widget(block, modal);
211
212        let fg = Style::default().fg(theme.fg.to_ratatui());
213        let muted = Style::default().fg(theme.gray.to_ratatui());
214
215        // Prompt row on top, body below.
216        let rows = Layout::default()
217            .direction(Direction::Vertical)
218            .constraints([Constraint::Length(2), Constraint::Min(0)])
219            .split(inner);
220
221        // "? " prefix, then the input widget (which owns caret + h-scroll).
222        let prompt_row = Rect {
223            height: 1,
224            ..rows[0]
225        };
226        f.render_widget(
227            Paragraph::new(Line::from(Span::styled(
228                "? ",
229                Style::default().fg(theme.accent.to_ratatui()),
230            ))),
231            prompt_row,
232        );
233        self.prompt
234            .render(f, prompt_row, fg, 2, matches!(self.state, State::Prompt));
235
236        match &self.state {
237            State::Prompt => {
238                f.render_widget(
239                    Paragraph::new("Type a question, then Enter.").style(muted),
240                    rows[1],
241                );
242            }
243            State::Loading => {
244                f.render_widget(Paragraph::new("Thinking…").style(muted), rows[1]);
245            }
246            State::Error(e) => {
247                f.render_widget(
248                    Paragraph::new(format!("Error: {e}"))
249                        .style(Style::default().fg(theme.red.to_ratatui()))
250                        .wrap(Wrap { trim: true }),
251                    rows[1],
252                );
253            }
254            State::Answered(answer) => {
255                let body = Layout::default()
256                    .direction(Direction::Vertical)
257                    .constraints([Constraint::Min(3), Constraint::Length(self.source_rows())])
258                    .split(rows[1]);
259                f.render_widget(
260                    Paragraph::new(answer.answer.clone())
261                        .style(fg)
262                        .wrap(Wrap { trim: true }),
263                    body[0],
264                );
265                let items: Vec<ListItem> = answer
266                    .sources
267                    .iter()
268                    .enumerate()
269                    .map(|(i, s)| {
270                        let selected = i == self.selected;
271                        let style = if selected {
272                            Style::default()
273                                .fg(theme.accent.to_ratatui())
274                                .add_modifier(Modifier::BOLD)
275                        } else {
276                            muted
277                        };
278                        let marker = if selected { "› " } else { "  " };
279                        ListItem::new(Line::from(vec![
280                            Span::styled(marker, style),
281                            Span::styled(format!("{} — {}", s.title, s.path), style),
282                        ]))
283                    })
284                    .collect();
285                f.render_widget(List::new(items), body[1]);
286            }
287        }
288    }
289}
290
291impl RagAnswerOverlay {
292    /// Height budget for the sources list (capped so the answer keeps room).
293    fn source_rows(&self) -> u16 {
294        (self.sources().len() as u16).min(8)
295    }
296}