1use std::ops::Range;
18use std::sync::Arc;
19
20use crate::server_client::RagClient;
21use ratatui::Frame;
22use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
23use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
24use ratatui::style::{Modifier, Style};
25use ratatui::text::{Line, Span};
26use ratatui::widgets::Paragraph;
27use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
28
29use crate::ask::{AskSource, Thread, Turn, TurnStatus, citations, save};
30use crate::components::Component;
31use crate::components::event_state::EventState;
32use crate::components::events::{AppEvent, AppTx, AskData, FileOp, InputEvent};
33use crate::components::hint_row::HintRow;
34use crate::components::panel::panel_block;
35use crate::components::single_line_input::{InputOutcome, SingleLineInput};
36use crate::settings::icons::Icons;
37use crate::settings::themes::Theme;
38
39const COMPOSER_HEIGHT: u16 = 3;
41
42const PAGE_OVERLAP: u16 = 2;
45
46type PendingTurn = (String, Vec<(String, String)>, u64);
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ThreadFocus {
54 Composer,
55 Turns,
56}
57
58enum RowSlot {
62 Turn(u64),
65 Answer {
73 turn_id: u64,
74 range: Range<usize>,
75 col_map: Vec<usize>,
76 },
77}
78
79pub struct ThreadPanel {
82 thread: Thread,
83 composer: SingleLineInput,
84 client: Option<Arc<RagClient>>,
90 focus: ThreadFocus,
91 scroll: u16,
96 follow_selection: bool,
100 bottom_follow_pending: bool,
104 turns_height: u16,
107 citation_target: Option<usize>,
111 turns_rect: Rect,
113 composer_rect: Rect,
115 actions: HintRow,
117 row_map: Vec<RowSlot>,
119 icons: Icons,
122}
123
124impl ThreadPanel {
125 pub fn new() -> Self {
126 Self {
127 thread: Thread::default(),
128 composer: SingleLineInput::new(),
129 client: None,
130 focus: ThreadFocus::Composer,
131 scroll: 0,
132 follow_selection: true,
133 bottom_follow_pending: false,
134 turns_height: 0,
135 citation_target: None,
136 turns_rect: Rect::default(),
137 composer_rect: Rect::default(),
138 actions: HintRow::new(&[
139 (KeyCode::Enter, "⏎", "Send"),
140 (KeyCode::Char('y'), "y", "Copy"),
141 (KeyCode::Char('e'), "e", "Save as note"),
142 (KeyCode::Char('r'), "r", "Regenerate"),
143 (KeyCode::Char('n'), "n", "New"),
144 ])
145 .with_indent(1),
146 row_map: Vec::new(),
147 icons: Icons::new(false),
148 }
149 }
150
151 pub fn set_icons(&mut self, icons: Icons) {
154 self.icons = icons;
155 }
156
157 pub fn set_client(&mut self, client: Option<Arc<RagClient>>) {
161 self.client = client;
162 }
163
164 pub fn has_client(&self) -> bool {
166 self.client.is_some()
167 }
168
169 pub fn focus_composer(&mut self) {
172 self.focus = ThreadFocus::Composer;
173 }
174
175 pub fn thread(&self) -> &Thread {
176 &self.thread
177 }
178
179 pub fn thread_mut(&mut self) -> &mut Thread {
180 &mut self.thread
181 }
182
183 pub fn take_citation_target(&mut self) -> Option<usize> {
186 self.citation_target.take()
187 }
188
189 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
195 match event {
196 InputEvent::Key(key) => self.handle_key(key, tx),
197 InputEvent::Mouse(mouse) => self.handle_mouse(mouse, tx),
198 InputEvent::Paste(_) => EventState::NotConsumed,
199 }
200 }
201
202 pub fn handle_data(&mut self, data: AskData) {
203 if let AskData::AnswerReady { turn_id, result } = data {
204 let completed_is_selected = self.thread.selected().map(|t| t.id) == Some(turn_id);
208 match result {
209 Ok((answer, sources)) => {
210 if self.thread.complete(turn_id, answer, sources) && completed_is_selected {
211 self.follow_bottom();
214 }
215 }
216 Err(e) => {
217 if self.thread.fail(turn_id, e) && completed_is_selected {
218 self.follow_bottom();
219 }
220 }
221 }
222 }
223 }
225
226 fn follow_bottom(&mut self) {
230 self.follow_selection = true;
231 self.bottom_follow_pending = true;
232 }
233
234 fn content_scroll_by(&mut self, delta: i32) {
238 self.follow_selection = false;
239 self.bottom_follow_pending = false;
240 self.scroll = if delta < 0 {
241 self.scroll.saturating_sub((-delta) as u16)
242 } else {
243 self.scroll.saturating_add(delta as u16)
244 };
245 }
246
247 fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
248 match self.focus {
249 ThreadFocus::Composer => self.handle_composer_key(key, tx),
250 ThreadFocus::Turns => self.handle_turns_key(key, tx),
251 }
252 }
253
254 fn handle_composer_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
255 if key.code == KeyCode::Esc {
256 self.focus = ThreadFocus::Turns;
257 return EventState::Consumed;
258 }
259 match self.composer.handle_key(key) {
260 InputOutcome::Submit => {
261 self.submit(tx);
262 EventState::Consumed
263 }
264 InputOutcome::NotConsumed => EventState::NotConsumed,
265 _ => EventState::Consumed,
266 }
267 }
268
269 fn handle_turns_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
270 let page = self.turns_height.saturating_sub(PAGE_OVERLAP).max(1) as i32;
273 match key.code {
274 KeyCode::Up | KeyCode::Char('k') => {
275 self.thread.select_prev();
276 self.follow_selection = true;
278 EventState::Consumed
279 }
280 KeyCode::Down | KeyCode::Char('j') => {
281 self.thread.select_next();
282 self.follow_selection = true;
283 EventState::Consumed
284 }
285 KeyCode::PageUp => {
288 self.content_scroll_by(-page);
289 EventState::Consumed
290 }
291 KeyCode::PageDown => {
292 self.content_scroll_by(page);
293 EventState::Consumed
294 }
295 KeyCode::Home => {
296 self.content_scroll_by(-(u16::MAX as i32));
297 EventState::Consumed
298 }
299 KeyCode::End => {
300 self.content_scroll_by(u16::MAX as i32);
301 EventState::Consumed
302 }
303 KeyCode::Char('i') | KeyCode::Char('/') => {
304 self.focus = ThreadFocus::Composer;
305 EventState::Consumed
306 }
307 KeyCode::Char('y') => {
308 self.copy_selected(tx);
309 EventState::Consumed
310 }
311 KeyCode::Char('e') => {
312 self.save_selected(tx);
313 EventState::Consumed
314 }
315 KeyCode::Char('r') => {
316 self.regenerate_selected(tx);
317 EventState::Consumed
318 }
319 _ => EventState::NotConsumed,
320 }
321 }
322
323 fn handle_mouse(&mut self, mouse: &MouseEvent, tx: &AppTx) -> EventState {
324 if let Some(key) = self.actions.hit(mouse) {
325 self.run_action(key.code, tx);
326 return EventState::Consumed;
327 }
328 let pos = Position {
329 x: mouse.column,
330 y: mouse.row,
331 };
332 match mouse.kind {
333 MouseEventKind::Down(MouseButton::Left) => {
334 if self.composer_rect.contains(pos) {
335 self.focus = ThreadFocus::Composer;
336 return EventState::Consumed;
337 }
338 if !self.turns_rect.contains(pos) {
339 return EventState::NotConsumed;
340 }
341 self.focus = ThreadFocus::Turns;
342 self.click_turns(mouse);
343 EventState::Consumed
344 }
345 MouseEventKind::ScrollUp if self.turns_rect.contains(pos) => {
346 self.content_scroll_by(-1);
347 EventState::Consumed
348 }
349 MouseEventKind::ScrollDown if self.turns_rect.contains(pos) => {
350 self.content_scroll_by(1);
351 EventState::Consumed
352 }
353 _ => EventState::NotConsumed,
354 }
355 }
356
357 fn run_action(&mut self, code: KeyCode, tx: &AppTx) {
361 match code {
362 KeyCode::Enter => self.submit(tx),
363 KeyCode::Char('y') => self.copy_selected(tx),
364 KeyCode::Char('e') => self.save_selected(tx),
365 KeyCode::Char('r') => self.regenerate_selected(tx),
366 KeyCode::Char('n') => {
369 tx.send(AppEvent::ExecuteLeaderAction(
370 crate::keys::leader::LeaderAction::AskNew,
371 ))
372 .ok();
373 }
374 _ => {}
375 }
376 }
377
378 fn sync_actions(&mut self) {
381 let has_turn = self.thread.selected().is_some();
382 self.actions.set_enabled(
383 0,
384 self.client.is_some() && !self.composer.value().trim().is_empty(),
385 );
386 for i in 1..=4 {
387 self.actions.set_enabled(i, has_turn);
388 }
389 }
390
391 fn click_turns(&mut self, mouse: &MouseEvent) {
396 let idx = (mouse.row - self.turns_rect.y) as usize;
397 let hit = self.row_map.get(idx).map(|slot| match slot {
398 RowSlot::Turn(id) => (*id, None),
399 RowSlot::Answer {
400 turn_id,
401 range,
402 col_map,
403 } => (*turn_id, Some((range.clone(), col_map.clone()))),
404 });
405 let Some((turn_id, answer_hit)) = hit else {
406 return;
407 };
408 self.select_turn(turn_id);
409 let Some((range, col_map)) = answer_hit else {
410 return;
411 };
412 let col = mouse.column.saturating_sub(self.turns_rect.x);
413 let Some(turn) = self.thread.selected() else {
414 return;
415 };
416 let Some(citation_idx) = citation_at_column(&turn.answer[range], &col_map, col) else {
417 return;
418 };
419 if turn.source_for_citation(citation_idx).is_some() {
422 self.citation_target = Some(citation_idx);
423 }
424 }
425
426 fn select_turn(&mut self, id: u64) {
429 if self.thread.selected().map(|t| t.id) == Some(id) {
430 return;
431 }
432 let Some(target_idx) = self.thread.turns().iter().position(|t| t.id == id) else {
433 return;
434 };
435 self.thread.select_index(target_idx);
436 }
437
438 fn begin_turn(&mut self) -> Option<PendingTurn> {
447 self.client.as_ref()?;
448 let question = self.composer.take_text();
449 let question = question.trim().to_string();
450 if question.is_empty() {
451 return None;
452 }
453 let history = self.thread.history();
457 let turn_id = self.thread.ask(question.clone());
458 self.follow_bottom();
460 Some((question, history, turn_id))
461 }
462
463 fn submit(&mut self, tx: &AppTx) {
467 let Some((question, history, turn_id)) = self.begin_turn() else {
468 return;
469 };
470 let Some(client) = self.client.clone() else {
471 return;
472 };
473 Self::spawn_ask(tx, &client, question, history, turn_id);
474 }
475
476 pub(crate) fn regenerate_selected(&mut self, tx: &AppTx) {
488 let Some(client) = self.client.clone() else {
489 return;
490 };
491 let Some(id) = self.thread.selected().map(|t| t.id) else {
492 return;
493 };
494 let Some(question) = self.thread.regenerate(id) else {
495 return;
496 };
497 let history = self.thread.history();
498 Self::spawn_ask(tx, &client, question, history, id);
499 }
500
501 fn spawn_ask(
507 tx: &AppTx,
508 client: &Arc<RagClient>,
509 question: String,
510 history: Vec<(String, String)>,
511 turn_id: u64,
512 ) {
513 let (tx, client) = (tx.clone(), client.clone());
514 tokio::spawn(async move {
515 let result = client
516 .ask(&question, &history, None)
517 .await
518 .map(|a| {
519 let sources = a
522 .sources
523 .into_iter()
524 .enumerate()
525 .map(|(i, c)| AskSource::from_chunk(i, c))
526 .collect();
527 (a.answer, sources)
528 })
529 .map_err(|e| e.to_string());
530 let _ = tx.send(AppEvent::Ask(AskData::AnswerReady { turn_id, result }));
531 });
532 }
533
534 pub(crate) fn copy_selected(&self, tx: &AppTx) {
538 let Some(turn) = self.thread.selected() else {
539 return;
540 };
541 let text = citations::strip(&turn.answer);
542 crate::components::yank(text, "answer copied", tx);
543 }
544
545 pub(crate) fn save_selected(&self, tx: &AppTx) {
549 let Some(turn) = self.thread.selected() else {
550 return;
551 };
552 let path = save::suggested_path(&turn.question);
553 let content = save::note_content(turn);
554 tx.send(AppEvent::FileOp(FileOp::ShowCreateWithContent {
555 path,
556 content,
557 }))
558 .ok();
559 }
560
561 fn render_turns(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
564 self.turns_rect = rect;
565
566 let question = Style::default()
569 .fg(theme.accent.to_ratatui())
570 .add_modifier(Modifier::BOLD);
571 let separator = Style::default()
572 .fg(theme.gray.to_ratatui())
573 .add_modifier(Modifier::DIM);
574 let dim = Style::default().fg(theme.gray.to_ratatui());
575 let err = Style::default().fg(theme.red.to_ratatui());
576 let md = crate::components::markdown_lines::MdStyles::from_theme(theme);
577 let prompt = self.icons.question_prompt;
578
579 let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
580 let mut turn_start_row: Vec<(u64, u16)> = Vec::new();
581 for (i, turn) in self.thread.turns().iter().enumerate() {
582 turn_start_row.push((turn.id, rows.len() as u16));
583 render_turn(
584 turn,
585 rect.width,
586 i == 0,
587 prompt,
588 question,
589 separator,
590 dim,
591 err,
592 &md,
593 &mut rows,
594 );
595 }
596 let total = rows.len() as u16;
597 let height = rect.height;
598 self.turns_height = height;
599
600 if let Some(sel) = self.thread.selected()
605 && let Some(&(_, start)) = turn_start_row.iter().find(|(id, _)| *id == sel.id)
606 {
607 let end = turn_start_row
610 .iter()
611 .map(|(_, s)| *s)
612 .filter(|s| *s > start)
613 .min()
614 .unwrap_or(total)
615 .saturating_sub(1);
616 if self.bottom_follow_pending {
617 if height > 0 {
618 self.scroll = end.saturating_sub(height - 1);
619 }
620 } else if self.follow_selection {
621 if start < self.scroll {
622 self.scroll = start;
623 } else if height > 0 && start >= self.scroll + height {
624 self.scroll = start.saturating_sub(height - 1);
625 }
626 }
627 }
628 self.bottom_follow_pending = false;
629 self.scroll = self.scroll.min(total.saturating_sub(height));
630
631 let selected_id = self.thread.selected().map(|t| t.id);
632 self.row_map.clear();
633 let mut lines: Vec<Line<'static>> = Vec::new();
634 for (slot, line) in rows
635 .into_iter()
636 .skip(self.scroll as usize)
637 .take(height as usize)
638 {
639 let row_turn_id = match &slot {
640 RowSlot::Turn(id) => *id,
641 RowSlot::Answer { turn_id, .. } => *turn_id,
642 };
643 let line = if focused && Some(row_turn_id) == selected_id {
644 line.style(Style::default().bg(theme.selection_bg.to_ratatui()))
645 } else {
646 line
647 };
648 self.row_map.push(slot);
649 lines.push(line);
650 }
651 f.render_widget(Paragraph::new(lines), rect);
652 }
653
654 fn render_composer(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
655 self.composer_rect = rect;
656
657 let enabled = self.client.is_some();
658 let title = if enabled {
659 "Ask a question"
660 } else {
661 "server unavailable"
662 };
663 let block = panel_block(title, theme, focused);
664 let inner = block.inner(rect);
665 f.render_widget(block, rect);
666
667 let style = if enabled {
668 Style::default().fg(theme.fg.to_ratatui())
669 } else {
670 Style::default()
671 .fg(theme.gray.to_ratatui())
672 .add_modifier(Modifier::DIM)
673 };
674 self.composer.render(f, inner, style, 0, focused && enabled);
675 }
676}
677
678impl Default for ThreadPanel {
679 fn default() -> Self {
680 Self::new()
681 }
682}
683
684impl Component for ThreadPanel {
685 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
686 let chunks = Layout::default()
687 .direction(Direction::Vertical)
688 .constraints([
689 Constraint::Min(0),
690 Constraint::Length(1),
691 Constraint::Length(COMPOSER_HEIGHT),
692 ])
693 .split(rect);
694 self.render_turns(
695 f,
696 chunks[0],
697 theme,
698 focused && self.focus == ThreadFocus::Turns,
699 );
700 self.sync_actions();
701 self.actions.render(
702 f,
703 chunks[1],
704 Style::default().fg(theme.gray.to_ratatui()),
705 theme,
706 );
707 self.render_composer(
708 f,
709 chunks[2],
710 theme,
711 focused && self.focus == ThreadFocus::Composer,
712 );
713 }
714
715 fn hint_shortcuts(&self) -> Vec<(String, String)> {
716 match self.focus {
717 ThreadFocus::Composer => vec![
718 ("Enter".into(), "Ask".into()),
719 ("Esc".into(), "Turns".into()),
720 ],
721 ThreadFocus::Turns => vec![
722 ("j/k".into(), "Select".into()),
723 ("PgUp/PgDn".into(), "Scroll".into()),
724 ("i//".into(), "Compose".into()),
725 ("y".into(), "Copy".into()),
726 ("e".into(), "Save as note".into()),
727 ("r".into(), "Regenerate".into()),
728 ],
729 }
730 }
731
732 }
736
737#[allow(clippy::too_many_arguments)]
741fn render_turn(
742 turn: &Turn,
743 width: u16,
744 is_first: bool,
745 prompt: &str,
746 question_style: Style,
747 sep_style: Style,
748 dim: Style,
749 err: Style,
750 md: &crate::components::markdown_lines::MdStyles,
751 out: &mut Vec<(RowSlot, Line<'static>)>,
752) {
753 if !is_first {
756 out.push((RowSlot::Turn(turn.id), separator_line(width, sep_style)));
757 }
758 let question = format!("{prompt} {}", turn.question);
759 for qline in wrap_text(&question, width) {
760 out.push((
761 RowSlot::Turn(turn.id),
762 Line::from(Span::styled(question[qline].to_string(), question_style)),
763 ));
764 }
765 match &turn.status {
766 TurnStatus::Thinking | TurnStatus::Streaming => {
767 out.push((
768 RowSlot::Turn(turn.id),
769 Line::from(Span::styled("… thinking", dim)),
770 ));
771 }
772 TurnStatus::Error(msg) => {
773 let text = format!("✗ {msg}");
774 for eline in wrap_text(&text, width) {
775 out.push((
776 RowSlot::Turn(turn.id),
777 Line::from(Span::styled(text[eline].to_string(), err)),
778 ));
779 }
780 out.push((
781 RowSlot::Turn(turn.id),
782 Line::from(Span::styled(" [r] retry", dim)),
783 ));
784 }
785 TurnStatus::Done => render_answer(turn, width, md, out),
786 }
787 out.push((RowSlot::Turn(turn.id), Line::default()));
788}
789
790fn separator_line(width: u16, style: Style) -> Line<'static> {
792 Line::from(Span::styled("─".repeat(width as usize), style))
793}
794
795fn render_answer(
805 turn: &Turn,
806 width: u16,
807 md: &crate::components::markdown_lines::MdStyles,
808 out: &mut Vec<(RowSlot, Line<'static>)>,
809) {
810 use crate::components::markdown_lines;
811 let logicals: Vec<&str> = turn
817 .answer
818 .split_inclusive('\n')
819 .map(|l| l.strip_suffix('\n').unwrap_or(l))
820 .collect();
821 let kinds = markdown_lines::classify_block_kinds(&logicals);
822 let mut offset = 0usize;
823 for (logical, &kind) in turn.answer.split_inclusive('\n').zip(kinds.iter()) {
824 let stripped = logical.strip_suffix('\n').unwrap_or(logical);
825 let line_start = offset;
826 for rel in wrap_text(stripped, width) {
827 let abs = (line_start + rel.start)..(line_start + rel.end);
828 let (line, col_map) =
829 markdown_lines::style_slice_mapped(&turn.answer[abs.clone()], kind, md);
830 out.push((
831 RowSlot::Answer {
832 turn_id: turn.id,
833 range: abs,
834 col_map,
835 },
836 line,
837 ));
838 }
839 offset += logical.len();
840 }
841}
842
843fn citation_at_column(slice: &str, map: &[usize], col: u16) -> Option<usize> {
850 let mut w: u16 = 0;
851 for &raw in map {
852 let ch = slice[raw..].chars().next()?;
853 let cw = (ch.width().unwrap_or(0) as u16).max(1);
854 if col < w + cw {
855 return citations::scan(slice)
856 .into_iter()
857 .find(|c| c.range.contains(&raw))
858 .map(|c| c.index);
859 }
860 w += cw;
861 }
862 None
863}
864
865fn wrap_text(text: &str, width: u16) -> Vec<Range<usize>> {
873 let width = width.max(1) as usize;
874 let mut lines = Vec::new();
875 let mut para_start = 0;
876 for (i, ch) in text.char_indices() {
877 if ch == '\n' {
878 wrap_paragraph(text, para_start..i, width, &mut lines);
879 para_start = i + 1;
880 }
881 }
882 wrap_paragraph(text, para_start..text.len(), width, &mut lines);
883 lines
884}
885
886fn wrap_paragraph(text: &str, para: Range<usize>, width: usize, out: &mut Vec<Range<usize>>) {
888 let words = word_ranges(text, para.clone());
889 let Some(first) = words.first() else {
890 out.push(para.start..para.start);
891 return;
892 };
893 let mut line_start = first.start;
894 let mut line_end = first.end;
895 let mut line_w = text[first.clone()].width();
896 for w in &words[1..] {
897 let word_w = text[w.clone()].width();
898 if line_w + 1 + word_w > width {
899 out.push(line_start..line_end);
900 line_start = w.start;
901 line_end = w.end;
902 line_w = word_w;
903 } else {
904 line_end = w.end;
905 line_w += 1 + word_w;
906 }
907 }
908 out.push(line_start..line_end);
909}
910
911fn word_ranges(text: &str, range: Range<usize>) -> Vec<Range<usize>> {
915 let bytes = text.as_bytes();
916 let mut words = Vec::new();
917 let mut i = range.start;
918 while i < range.end {
919 while i < range.end && bytes[i] == b' ' {
920 i += 1;
921 }
922 if i >= range.end {
923 break;
924 }
925 let start = i;
926 while i < range.end && bytes[i] != b' ' {
927 i += 1;
928 }
929 words.push(start..i);
930 }
931 words
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use ratatui::crossterm::event::KeyModifiers;
938
939 fn test_client() -> Arc<RagClient> {
942 Arc::new(RagClient::new(
943 "http://localhost:0".to_string(),
944 None,
945 "vault".to_string(),
946 ))
947 }
948
949 fn test_panel() -> ThreadPanel {
951 let mut p = ThreadPanel::new();
952 p.composer.set_value("q");
953 p
954 }
955
956 fn test_panel_online() -> ThreadPanel {
957 let mut p = ThreadPanel::new();
958 p.set_client(Some(test_client()));
959 p
960 }
961
962 fn p_handle_enter(p: &mut ThreadPanel) -> EventState {
963 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
964 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
965 p.handle_input(&InputEvent::Key(key), &tx)
966 }
967
968 #[test]
969 fn new_thread_panel_starts_empty_without_client_and_composer_focus() {
970 let panel = ThreadPanel::new();
971 assert!(panel.thread().is_empty());
972 assert!(!panel.has_client());
973 assert_eq!(panel.focus, ThreadFocus::Composer);
974 }
975
976 #[test]
977 fn set_client_toggles_the_composer_enable_signal() {
978 let mut panel = ThreadPanel::new();
979 assert!(!panel.has_client());
980 panel.set_client(Some(test_client()));
981 assert!(panel.has_client());
982 panel.set_client(None);
983 assert!(!panel.has_client());
984 }
985
986 #[test]
987 fn thread_mut_allows_mutating_the_conversation() {
988 let mut panel = ThreadPanel::new();
989 panel.thread_mut().ask("q?".to_string());
990 assert_eq!(panel.thread().turns().len(), 1);
991 }
992
993 #[tokio::test]
994 async fn enter_submits_only_with_a_client() {
995 let mut p = test_panel(); let _ = p_handle_enter(&mut p);
997 assert!(p.thread().is_empty(), "no client → no turn");
998
999 p.set_client(Some(test_client()));
1003 let _ = p_handle_enter(&mut p);
1004 assert_eq!(p.thread().turns().len(), 1);
1005 assert!(matches!(
1006 p.thread().selected().unwrap().status,
1007 TurnStatus::Thinking
1008 ));
1009 }
1010
1011 #[test]
1012 fn answer_ready_completes_matching_turn_only() {
1013 let mut p = test_panel_online();
1014 let id = p.thread_mut().ask("q".into());
1015 p.handle_data(AskData::AnswerReady {
1016 turn_id: 999,
1017 result: Ok(("x".into(), vec![])),
1018 });
1019 assert!(matches!(
1020 p.thread().selected().unwrap().status,
1021 TurnStatus::Thinking
1022 ));
1023 p.handle_data(AskData::AnswerReady {
1024 turn_id: id,
1025 result: Ok(("a".into(), vec![])),
1026 });
1027 assert!(matches!(
1028 p.thread().selected().unwrap().status,
1029 TurnStatus::Done
1030 ));
1031 }
1032
1033 #[test]
1034 fn begin_turn_is_none_without_a_client() {
1035 let mut p = ThreadPanel::new(); p.composer.set_value("hello");
1037 assert!(p.begin_turn().is_none());
1038 assert!(
1039 p.thread().is_empty(),
1040 "no client → no orphaned Thinking turn"
1041 );
1042 }
1043
1044 #[test]
1045 fn begin_turn_is_none_when_composer_empty() {
1046 let mut p = ThreadPanel::new();
1047 p.set_client(Some(test_client()));
1048 p.composer.set_value(" ");
1049 assert!(p.begin_turn().is_none());
1050 assert!(p.thread().is_empty());
1051 }
1052
1053 #[test]
1054 fn begin_turn_pushes_a_thinking_turn_and_selects_it() {
1055 let mut p = ThreadPanel::new();
1056 p.set_client(Some(test_client()));
1057 p.composer.set_value("hello");
1058 let (question, history, turn_id) = p.begin_turn().expect("client + non-empty");
1059 assert_eq!(question, "hello");
1060 assert!(history.is_empty());
1061 assert_eq!(p.thread().turns().len(), 1);
1062 assert_eq!(p.thread().selected().unwrap().id, turn_id);
1063 }
1064
1065 #[test]
1066 fn esc_in_composer_moves_focus_to_turns() {
1067 let mut p = ThreadPanel::new();
1068 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1069 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1070 let state = p.handle_input(&InputEvent::Key(key), &tx);
1071 assert_eq!(state, EventState::Consumed);
1072 assert_eq!(p.focus, ThreadFocus::Turns);
1073 }
1074
1075 #[test]
1076 fn jk_in_turns_moves_selection() {
1077 let mut p = ThreadPanel::new();
1078 let first = p.thread_mut().ask("a".into());
1079 p.thread_mut().complete(first, "a!".into(), vec![]);
1080 let second = p.thread_mut().ask("b".into());
1081 p.thread_mut().complete(second, "b!".into(), vec![]);
1082 p.focus = ThreadFocus::Turns;
1083
1084 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1085 let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
1086 p.handle_input(&InputEvent::Key(key), &tx);
1087 assert_eq!(p.thread().selected().unwrap().id, first);
1088 }
1089
1090 #[test]
1091 fn regenerate_without_a_client_does_nothing() {
1092 let mut p = ThreadPanel::new();
1095 let id = p.thread_mut().ask("q".into());
1096 p.thread_mut().complete(id, "a".into(), vec![]);
1097 p.focus = ThreadFocus::Turns;
1098
1099 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1100 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
1101 p.handle_input(&InputEvent::Key(key), &tx);
1102 assert!(
1103 matches!(p.thread().selected().unwrap().status, TurnStatus::Done),
1104 "no client → the completed turn stays Done"
1105 );
1106 assert_eq!(p.thread().selected().unwrap().id, id);
1107 }
1108
1109 #[test]
1110 fn i_and_slash_move_focus_to_composer() {
1111 for ch in ['i', '/'] {
1112 let mut p = ThreadPanel::new();
1113 p.focus = ThreadFocus::Turns;
1114 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1115 let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
1116 p.handle_input(&InputEvent::Key(key), &tx);
1117 assert_eq!(p.focus, ThreadFocus::Composer);
1118 }
1119 }
1120
1121 #[test]
1122 fn wrap_text_breaks_on_spaces_within_width() {
1123 let lines = wrap_text("one two three", 7);
1124 let text = "one two three";
1125 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1126 assert_eq!(rendered, vec!["one two", "three"]);
1127 }
1128
1129 #[test]
1130 fn wrap_text_keeps_an_overlong_word_on_its_own_line() {
1131 let lines = wrap_text("a superlongword b", 5);
1132 let text = "a superlongword b";
1133 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1134 assert_eq!(rendered, vec!["a", "superlongword", "b"]);
1135 }
1136
1137 #[test]
1138 fn wrap_text_forces_a_break_on_newline() {
1139 let lines = wrap_text("a\nb", 10);
1140 let text = "a\nb";
1141 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1142 assert_eq!(rendered, vec!["a", "b"]);
1143 }
1144
1145 fn identity_map(slice: &str) -> Vec<usize> {
1148 slice.char_indices().map(|(i, _)| i).collect()
1149 }
1150
1151 #[test]
1152 fn citation_at_column_finds_the_marker_under_the_click() {
1153 let text = "Fact [1] more";
1154 let map = identity_map(text);
1155 let idx = citation_at_column(text, &map, 5);
1156 assert_eq!(idx, Some(1));
1157 let idx = citation_at_column(text, &map, 0);
1158 assert_eq!(idx, None);
1159 }
1160
1161 #[test]
1165 fn citation_hit_test_resolves_through_hidden_emphasis() {
1166 use crate::components::markdown_lines::{self, LineKind, MdStyles};
1167 let md = MdStyles::from_theme(&Theme::default());
1168 let raw = "**bold** then [1] tail";
1169 let (line, col_map) = markdown_lines::style_slice_mapped(raw, LineKind::Normal, &md);
1170 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1171 assert_eq!(text, "bold then [1] tail");
1172 let col = text.find("[1]").unwrap() as u16 + 1; assert_eq!(citation_at_column(raw, &col_map, col), Some(1));
1175 }
1176
1177 #[test]
1178 fn click_turns_selects_turn_and_resolves_citation_target() {
1179 let mut p = ThreadPanel::new();
1180 let first = p.thread_mut().ask("a".into());
1181 p.thread_mut().complete(
1182 first,
1183 "See [1] for it".into(),
1184 vec![AskSource {
1185 path: kimun_core::nfs::VaultPath::new("a.md"),
1186 heading: "h".into(),
1187 date: None,
1188 score: 1.0,
1189 text: String::new(),
1190 ordinal: 1,
1191 }],
1192 );
1193 let second = p.thread_mut().ask("b".into());
1194 p.thread_mut().complete(second, "b!".into(), vec![]);
1195 p.turns_rect = Rect::new(0, 0, 40, 20);
1197 let answer_slice = "See [1] for it";
1198 p.row_map = vec![
1199 RowSlot::Turn(first),
1200 RowSlot::Answer {
1201 turn_id: first,
1202 range: 0..answer_slice.len(),
1203 col_map: identity_map(answer_slice),
1204 },
1205 RowSlot::Turn(first),
1206 RowSlot::Turn(second),
1207 ];
1208 let mouse = MouseEvent {
1209 kind: MouseEventKind::Down(MouseButton::Left),
1210 column: 4, row: 1,
1212 modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
1213 };
1214 p.click_turns(&mouse);
1215 assert_eq!(p.thread().selected().unwrap().id, first);
1216 assert_eq!(p.take_citation_target(), Some(1));
1218 }
1219
1220 #[test]
1224 fn separators_part_turns_and_question_line_stands_out() {
1225 use crate::components::markdown_lines::MdStyles;
1226 let theme = Theme::default();
1227 let md = MdStyles::from_theme(&theme);
1228 let qstyle = Style::default()
1229 .fg(theme.accent.to_ratatui())
1230 .add_modifier(Modifier::BOLD);
1231 let sep = Style::default()
1232 .fg(theme.gray.to_ratatui())
1233 .add_modifier(Modifier::DIM);
1234 let dim = Style::default();
1235 let err = Style::default();
1236
1237 let mut thread = Thread::default();
1238 let a = thread.ask("first".into());
1239 thread.complete(a, "ans a".into(), vec![]);
1240 let b = thread.ask("second".into());
1241 thread.complete(b, "ans b".into(), vec![]);
1242
1243 let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
1244 for (i, turn) in thread.turns().iter().enumerate() {
1245 render_turn(turn, 40, i == 0, ">", qstyle, sep, dim, err, &md, &mut rows);
1246 }
1247
1248 let is_sep = |l: &Line<'static>| l.spans.iter().any(|s| s.content.contains('─'));
1249 assert_eq!(rows.iter().filter(|(_, l)| is_sep(l)).count(), 1);
1251 assert!(!is_sep(&rows[0].1), "no rule before the first turn");
1252 let sep_idx = rows.iter().position(|(_, l)| is_sep(l)).unwrap();
1253 assert!(matches!(rows[sep_idx].0, RowSlot::Turn(id) if id == b));
1254 assert_ne!(sep_idx, rows.len() - 1, "no rule after the last turn");
1255
1256 let (_, qline) = rows
1258 .iter()
1259 .find(|(_, l)| l.spans.iter().any(|s| s.content.contains("first")))
1260 .unwrap();
1261 assert!(
1262 qline.spans[0].content.starts_with('>'),
1263 "carries the prompt"
1264 );
1265 assert_eq!(qline.spans[0].style, qstyle, "accent + bold");
1266 }
1267
1268 mod rendering {
1269 use super::*;
1270 use crate::settings::themes::Theme;
1271 use ratatui::Terminal;
1272 use ratatui::backend::TestBackend;
1273
1274 fn draw(p: &mut ThreadPanel, theme: &Theme, width: u16, height: u16, focused: bool) {
1275 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1276 terminal
1277 .draw(|f| {
1278 let area = f.area();
1279 p.render(f, area, theme, focused);
1280 })
1281 .unwrap();
1282 }
1283
1284 #[test]
1287 fn clicking_a_separator_row_selects_its_turn() {
1288 let theme = Theme::default();
1289 let mut p = ThreadPanel::new();
1290 let a = p.thread_mut().ask("first".into());
1291 p.thread_mut().complete(a, "aaa".into(), vec![]);
1292 let b = p.thread_mut().ask("second".into());
1293 p.thread_mut().complete(b, "bbb".into(), vec![]);
1294 p.focus = ThreadFocus::Turns;
1295 p.thread_mut().select_index(0);
1297 draw(&mut p, &theme, 40, 12, true);
1298
1299 let sep_row = p
1301 .row_map
1302 .iter()
1303 .position(|s| matches!(s, RowSlot::Turn(id) if *id == b))
1304 .expect("turn b has rows on screen");
1305 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1306 let mouse = MouseEvent {
1307 kind: MouseEventKind::Down(MouseButton::Left),
1308 column: 0,
1309 row: p.turns_rect.y + sep_row as u16,
1310 modifiers: KeyModifiers::NONE,
1311 };
1312 p.handle_input(&InputEvent::Mouse(mouse), &tx);
1313 assert_eq!(p.thread().selected().unwrap().id, b);
1314 }
1315
1316 #[test]
1317 fn render_does_not_panic_across_states_and_sizes() {
1318 let theme = Theme::default();
1319 let mut p = ThreadPanel::new();
1320 p.set_client(Some(test_client())); draw(&mut p, &theme, 40, 10, true); let id = p
1324 .thread_mut()
1325 .ask("A fairly long question that should wrap across more than one line".into());
1326 draw(&mut p, &theme, 40, 10, true); p.thread_mut().complete(
1329 id,
1330 "An answer citing [1] a source and [2] another, spanning multiple \
1331 wrapped lines to exercise citation styling."
1332 .into(),
1333 vec![],
1334 );
1335 draw(&mut p, &theme, 40, 10, true); p.focus = ThreadFocus::Turns;
1337 draw(&mut p, &theme, 40, 10, true);
1338
1339 let id2 = p.thread_mut().ask("another".into());
1340 p.thread_mut().fail(id2, "boom".into());
1341 draw(&mut p, &theme, 40, 10, true); p.set_client(None);
1344 draw(&mut p, &theme, 40, 10, false); draw(&mut p, &theme, 3, 3, true); draw(&mut p, &theme, 0, 0, true); }
1349
1350 fn turns_key(p: &mut ThreadPanel, code: KeyCode) {
1351 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1352 p.handle_input(
1353 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
1354 &tx,
1355 );
1356 }
1357
1358 #[test]
1364 fn markdown_answer_keeps_prose_citations_clickable() {
1365 let theme = Theme::default();
1366 let mut p = ThreadPanel::new();
1367 let id = p.thread_mut().ask("q".into());
1368 let answer = "# Title\nSee [1] here.\n```\nlet x = arr[9];\n```".to_string();
1369 p.thread_mut().complete(
1370 id,
1371 answer.clone(),
1372 vec![AskSource {
1373 path: kimun_core::nfs::VaultPath::new("a.md"),
1374 heading: "h".into(),
1375 date: None,
1376 score: 1.0,
1377 text: String::new(),
1378 ordinal: 1,
1379 }],
1380 );
1381 p.focus = ThreadFocus::Turns;
1382 draw(&mut p, &theme, 60, 12, true);
1383
1384 let hit = p.row_map.iter().find_map(|slot| match slot {
1387 RowSlot::Answer { range, col_map, .. } if answer[range.clone()].contains("[1]") => {
1388 let slice = &answer[range.clone()];
1389 let col = slice.find("[1]").unwrap() as u16 + 1;
1390 Some(citation_at_column(slice, col_map, col))
1391 }
1392 _ => None,
1393 });
1394 assert_eq!(
1395 hit,
1396 Some(Some(1)),
1397 "the prose citation resolves through the rendered slice"
1398 );
1399 }
1400
1401 fn click_text(p: &mut ThreadPanel, text: &str) -> Vec<AppEvent> {
1403 use ratatui::{Terminal, backend::TestBackend};
1404 let theme = Theme::default();
1405 let mut t = Terminal::new(TestBackend::new(80, 12)).unwrap();
1406 t.draw(|f| p.render(f, f.area(), &theme, true)).unwrap();
1407 let (x, y) = crate::test_support::find_text(t.backend().buffer(), text)
1408 .unwrap_or_else(|| panic!("{text:?} not drawn"));
1409 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1410 p.handle_input(&crate::test_support::mouse_down_at(x, y), &tx);
1411 crate::test_support::drain(&mut rx).into_iter().collect()
1412 }
1413
1414 #[test]
1417 fn action_chips_act_from_any_focus() {
1418 let mut p = ThreadPanel::new();
1419 p.set_client(Some(test_client()));
1420 let id = p.thread_mut().ask("q".into());
1421 p.thread_mut().complete(id, "answer".into(), vec![]);
1422 p.focus = ThreadFocus::Composer;
1423 let ev = click_text(&mut p, "[e] Save as note");
1424 assert!(
1425 ev.iter().any(|e| matches!(e, AppEvent::FileOp(_))),
1426 "save as note opens the create dialog: {ev:?}"
1427 );
1428 let ev = click_text(&mut p, "[n] New");
1429 assert!(ev.iter().any(|e| matches!(
1430 e,
1431 AppEvent::ExecuteLeaderAction(crate::keys::leader::LeaderAction::AskNew)
1432 )));
1433 }
1434
1435 #[test]
1437 fn turn_chips_are_inert_without_a_turn() {
1438 let mut p = ThreadPanel::new();
1439 p.set_client(Some(test_client()));
1440 assert!(click_text(&mut p, "[n] New").is_empty());
1441 assert!(click_text(&mut p, "[⏎] Send").is_empty(), "empty question");
1442 }
1443
1444 #[test]
1447 fn completion_bottom_follows_to_show_the_answer_end() {
1448 let theme = Theme::default();
1449 let mut p = ThreadPanel::new();
1450 p.set_client(Some(test_client()));
1451 let id = p.thread_mut().ask("q".into());
1452 p.focus = ThreadFocus::Turns;
1453 let answer = (0..10)
1455 .map(|i| format!("line{i}"))
1456 .collect::<Vec<_>>()
1457 .join("\n");
1458 p.handle_data(AskData::AnswerReady {
1459 turn_id: id,
1460 result: Ok((answer, vec![])),
1461 });
1462 draw(&mut p, &theme, 60, 8, true);
1466 assert_eq!(p.scroll, 8, "bottom-follow shows the answer's end");
1467 }
1468
1469 #[test]
1473 fn completion_of_an_unselected_turn_leaves_scroll_untouched() {
1474 let mut p = ThreadPanel::new();
1475 p.set_client(Some(test_client()));
1476 let old = p.thread_mut().ask("old".into());
1477 p.thread_mut().complete(old, "old answer".into(), vec![]);
1478 let new = p.thread_mut().ask("new".into());
1479 p.thread_mut().complete(new, "new answer".into(), vec![]);
1480 p.thread_mut().select_last();
1483 p.scroll = 4;
1484 p.follow_selection = false;
1485 p.bottom_follow_pending = false;
1486
1487 p.handle_data(AskData::AnswerReady {
1489 turn_id: old,
1490 result: Ok(("regenerated".into(), vec![])),
1491 });
1492 assert_eq!(p.scroll, 4, "unselected completion must not move scroll");
1493 assert!(!p.follow_selection, "follow flags untouched");
1494 assert!(!p.bottom_follow_pending, "bottom-follow not armed");
1495
1496 let newer = p.thread_mut().ask("newer".into());
1498 p.handle_data(AskData::AnswerReady {
1499 turn_id: newer,
1500 result: Ok(("visible".into(), vec![])),
1501 });
1502 assert!(
1503 p.bottom_follow_pending,
1504 "selected completion follows bottom"
1505 );
1506 }
1507
1508 #[test]
1511 fn selection_scrolls_into_view_and_content_scroll_clamps() {
1512 let theme = Theme::default();
1513 let mut p = ThreadPanel::new();
1514 for i in 0..8 {
1515 let id = p.thread_mut().ask(format!("q{i}"));
1516 p.thread_mut().complete(id, format!("a{i}"), vec![]);
1517 }
1518 p.focus = ThreadFocus::Turns;
1519 draw(&mut p, &theme, 60, 9, true); for _ in 0..8 {
1527 turns_key(&mut p, KeyCode::Char('k'));
1528 }
1529 draw(&mut p, &theme, 60, 9, true);
1530 assert_eq!(
1531 p.scroll, 0,
1532 "selecting the first turn scrolled it into view"
1533 );
1534
1535 turns_key(&mut p, KeyCode::End);
1537 draw(&mut p, &theme, 60, 9, true);
1538 assert_eq!(p.scroll, 26, "content scroll clamps to the last page");
1539 }
1540 }
1541}