1use 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 Prompt,
28 Loading,
30 Answered(RagAnswer),
32 Error(String),
34}
35
36static 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: usize,
47 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 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 _ => EventState::Consumed,
120 },
121 }
122 }
123
124 fn handle_prompt_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
125 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, }
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 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 let rows = Layout::default()
217 .direction(Direction::Vertical)
218 .constraints([Constraint::Length(2), Constraint::Min(0)])
219 .split(inner);
220
221 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 fn source_rows(&self) -> u16 {
294 (self.sources().len() as u16).min(8)
295 }
296}