1use std::ops::Range;
18use std::sync::Arc;
19
20use kimun_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::panel::panel_block;
34use crate::components::single_line_input::{InputOutcome, SingleLineInput};
35use crate::settings::icons::Icons;
36use crate::settings::themes::Theme;
37
38const COMPOSER_HEIGHT: u16 = 3;
40
41const PAGE_OVERLAP: u16 = 2;
44
45type PendingTurn = (String, Vec<(String, String)>, u64);
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ThreadFocus {
53 Composer,
54 Turns,
55}
56
57enum RowSlot {
61 Turn(u64),
64 Answer {
72 turn_id: u64,
73 range: Range<usize>,
74 col_map: Vec<usize>,
75 },
76}
77
78pub struct ThreadPanel {
81 thread: Thread,
82 composer: SingleLineInput,
83 client: Option<Arc<RagClient>>,
89 focus: ThreadFocus,
90 scroll: u16,
95 follow_selection: bool,
99 bottom_follow_pending: bool,
103 turns_height: u16,
106 citation_target: Option<usize>,
110 turns_rect: Rect,
112 composer_rect: Rect,
114 row_map: Vec<RowSlot>,
116 icons: Icons,
119}
120
121impl ThreadPanel {
122 pub fn new() -> Self {
123 Self {
124 thread: Thread::default(),
125 composer: SingleLineInput::new(),
126 client: None,
127 focus: ThreadFocus::Composer,
128 scroll: 0,
129 follow_selection: true,
130 bottom_follow_pending: false,
131 turns_height: 0,
132 citation_target: None,
133 turns_rect: Rect::default(),
134 composer_rect: Rect::default(),
135 row_map: Vec::new(),
136 icons: Icons::new(false),
137 }
138 }
139
140 pub fn set_icons(&mut self, icons: Icons) {
143 self.icons = icons;
144 }
145
146 pub fn set_client(&mut self, client: Option<Arc<RagClient>>) {
150 self.client = client;
151 }
152
153 pub fn has_client(&self) -> bool {
155 self.client.is_some()
156 }
157
158 pub fn focus_composer(&mut self) {
161 self.focus = ThreadFocus::Composer;
162 }
163
164 pub fn thread(&self) -> &Thread {
165 &self.thread
166 }
167
168 pub fn thread_mut(&mut self) -> &mut Thread {
169 &mut self.thread
170 }
171
172 pub fn take_citation_target(&mut self) -> Option<usize> {
175 self.citation_target.take()
176 }
177
178 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
184 match event {
185 InputEvent::Key(key) => self.handle_key(key, tx),
186 InputEvent::Mouse(mouse) => self.handle_mouse(mouse, tx),
187 InputEvent::Paste(_) => EventState::NotConsumed,
188 }
189 }
190
191 pub fn handle_data(&mut self, data: AskData) {
192 if let AskData::AnswerReady { turn_id, result } = data {
193 let completed_is_selected = self.thread.selected().map(|t| t.id) == Some(turn_id);
197 match result {
198 Ok((answer, sources)) => {
199 if self.thread.complete(turn_id, answer, sources) && completed_is_selected {
200 self.follow_bottom();
203 }
204 }
205 Err(e) => {
206 if self.thread.fail(turn_id, e) && completed_is_selected {
207 self.follow_bottom();
208 }
209 }
210 }
211 }
212 }
214
215 fn follow_bottom(&mut self) {
219 self.follow_selection = true;
220 self.bottom_follow_pending = true;
221 }
222
223 fn content_scroll_by(&mut self, delta: i32) {
227 self.follow_selection = false;
228 self.bottom_follow_pending = false;
229 self.scroll = if delta < 0 {
230 self.scroll.saturating_sub((-delta) as u16)
231 } else {
232 self.scroll.saturating_add(delta as u16)
233 };
234 }
235
236 fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
237 match self.focus {
238 ThreadFocus::Composer => self.handle_composer_key(key, tx),
239 ThreadFocus::Turns => self.handle_turns_key(key, tx),
240 }
241 }
242
243 fn handle_composer_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
244 if key.code == KeyCode::Esc {
245 self.focus = ThreadFocus::Turns;
246 return EventState::Consumed;
247 }
248 match self.composer.handle_key(key) {
249 InputOutcome::Submit => {
250 self.submit(tx);
251 EventState::Consumed
252 }
253 InputOutcome::NotConsumed => EventState::NotConsumed,
254 _ => EventState::Consumed,
255 }
256 }
257
258 fn handle_turns_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
259 let page = self.turns_height.saturating_sub(PAGE_OVERLAP).max(1) as i32;
262 match key.code {
263 KeyCode::Up | KeyCode::Char('k') => {
264 self.thread.select_prev();
265 self.follow_selection = true;
267 EventState::Consumed
268 }
269 KeyCode::Down | KeyCode::Char('j') => {
270 self.thread.select_next();
271 self.follow_selection = true;
272 EventState::Consumed
273 }
274 KeyCode::PageUp => {
277 self.content_scroll_by(-page);
278 EventState::Consumed
279 }
280 KeyCode::PageDown => {
281 self.content_scroll_by(page);
282 EventState::Consumed
283 }
284 KeyCode::Home => {
285 self.content_scroll_by(-(u16::MAX as i32));
286 EventState::Consumed
287 }
288 KeyCode::End => {
289 self.content_scroll_by(u16::MAX as i32);
290 EventState::Consumed
291 }
292 KeyCode::Char('i') | KeyCode::Char('/') => {
293 self.focus = ThreadFocus::Composer;
294 EventState::Consumed
295 }
296 KeyCode::Char('y') => {
297 self.copy_selected(tx);
298 EventState::Consumed
299 }
300 KeyCode::Char('e') => {
301 self.save_selected(tx);
302 EventState::Consumed
303 }
304 KeyCode::Char('r') => {
305 self.regenerate_selected(tx);
306 EventState::Consumed
307 }
308 _ => EventState::NotConsumed,
309 }
310 }
311
312 fn handle_mouse(&mut self, mouse: &MouseEvent, _tx: &AppTx) -> EventState {
313 let pos = Position {
314 x: mouse.column,
315 y: mouse.row,
316 };
317 match mouse.kind {
318 MouseEventKind::Down(MouseButton::Left) => {
319 if self.composer_rect.contains(pos) {
320 self.focus = ThreadFocus::Composer;
321 return EventState::Consumed;
322 }
323 if !self.turns_rect.contains(pos) {
324 return EventState::NotConsumed;
325 }
326 self.focus = ThreadFocus::Turns;
327 self.click_turns(mouse);
328 EventState::Consumed
329 }
330 MouseEventKind::ScrollUp if self.turns_rect.contains(pos) => {
331 self.content_scroll_by(-1);
332 EventState::Consumed
333 }
334 MouseEventKind::ScrollDown if self.turns_rect.contains(pos) => {
335 self.content_scroll_by(1);
336 EventState::Consumed
337 }
338 _ => EventState::NotConsumed,
339 }
340 }
341
342 fn click_turns(&mut self, mouse: &MouseEvent) {
347 let idx = (mouse.row - self.turns_rect.y) as usize;
348 let hit = self.row_map.get(idx).map(|slot| match slot {
349 RowSlot::Turn(id) => (*id, None),
350 RowSlot::Answer {
351 turn_id,
352 range,
353 col_map,
354 } => (*turn_id, Some((range.clone(), col_map.clone()))),
355 });
356 let Some((turn_id, answer_hit)) = hit else {
357 return;
358 };
359 self.select_turn(turn_id);
360 let Some((range, col_map)) = answer_hit else {
361 return;
362 };
363 let col = mouse.column.saturating_sub(self.turns_rect.x);
364 let Some(turn) = self.thread.selected() else {
365 return;
366 };
367 let Some(citation_idx) = citation_at_column(&turn.answer[range], &col_map, col) else {
368 return;
369 };
370 if turn.source_for_citation(citation_idx).is_some() {
373 self.citation_target = Some(citation_idx);
374 }
375 }
376
377 fn select_turn(&mut self, id: u64) {
380 if self.thread.selected().map(|t| t.id) == Some(id) {
381 return;
382 }
383 let Some(target_idx) = self.thread.turns().iter().position(|t| t.id == id) else {
384 return;
385 };
386 self.thread.select_index(target_idx);
387 }
388
389 fn begin_turn(&mut self) -> Option<PendingTurn> {
398 self.client.as_ref()?;
399 let question = self.composer.take_text();
400 let question = question.trim().to_string();
401 if question.is_empty() {
402 return None;
403 }
404 let history = self.thread.history();
408 let turn_id = self.thread.ask(question.clone());
409 self.follow_bottom();
411 Some((question, history, turn_id))
412 }
413
414 fn submit(&mut self, tx: &AppTx) {
418 let Some((question, history, turn_id)) = self.begin_turn() else {
419 return;
420 };
421 let Some(client) = self.client.clone() else {
422 return;
423 };
424 Self::spawn_ask(tx, &client, question, history, turn_id);
425 }
426
427 pub(crate) fn regenerate_selected(&mut self, tx: &AppTx) {
439 let Some(client) = self.client.clone() else {
440 return;
441 };
442 let Some(id) = self.thread.selected().map(|t| t.id) else {
443 return;
444 };
445 let Some(question) = self.thread.regenerate(id) else {
446 return;
447 };
448 let history = self.thread.history();
449 Self::spawn_ask(tx, &client, question, history, id);
450 }
451
452 fn spawn_ask(
458 tx: &AppTx,
459 client: &Arc<RagClient>,
460 question: String,
461 history: Vec<(String, String)>,
462 turn_id: u64,
463 ) {
464 let (tx, client) = (tx.clone(), client.clone());
465 tokio::spawn(async move {
466 let result = client
467 .ask(&question, &history, None)
468 .await
469 .map(|a| {
470 let sources = a
473 .sources
474 .into_iter()
475 .enumerate()
476 .map(|(i, c)| AskSource::from_chunk(i, c))
477 .collect();
478 (a.answer, sources)
479 })
480 .map_err(|e| e.to_string());
481 let _ = tx.send(AppEvent::Ask(AskData::AnswerReady { turn_id, result }));
482 });
483 }
484
485 pub(crate) fn copy_selected(&self, tx: &AppTx) {
489 let Some(turn) = self.thread.selected() else {
490 return;
491 };
492 let text = citations::strip(&turn.answer);
493 crate::components::yank(text, "answer copied", tx);
494 }
495
496 pub(crate) fn save_selected(&self, tx: &AppTx) {
500 let Some(turn) = self.thread.selected() else {
501 return;
502 };
503 let path = save::suggested_path(&turn.question);
504 let content = save::note_content(turn);
505 tx.send(AppEvent::FileOp(FileOp::ShowCreateWithContent {
506 path,
507 content,
508 }))
509 .ok();
510 }
511
512 fn render_turns(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
515 self.turns_rect = rect;
516
517 let question = Style::default()
520 .fg(theme.accent.to_ratatui())
521 .add_modifier(Modifier::BOLD);
522 let separator = Style::default()
523 .fg(theme.gray.to_ratatui())
524 .add_modifier(Modifier::DIM);
525 let dim = Style::default().fg(theme.gray.to_ratatui());
526 let err = Style::default().fg(theme.red.to_ratatui());
527 let md = crate::components::markdown_lines::MdStyles::from_theme(theme);
528 let prompt = self.icons.question_prompt;
529
530 let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
531 let mut turn_start_row: Vec<(u64, u16)> = Vec::new();
532 for (i, turn) in self.thread.turns().iter().enumerate() {
533 turn_start_row.push((turn.id, rows.len() as u16));
534 render_turn(
535 turn,
536 rect.width,
537 i == 0,
538 prompt,
539 question,
540 separator,
541 dim,
542 err,
543 &md,
544 &mut rows,
545 );
546 }
547 let total = rows.len() as u16;
548 let height = rect.height;
549 self.turns_height = height;
550
551 if let Some(sel) = self.thread.selected()
556 && let Some(&(_, start)) = turn_start_row.iter().find(|(id, _)| *id == sel.id)
557 {
558 let end = turn_start_row
561 .iter()
562 .map(|(_, s)| *s)
563 .filter(|s| *s > start)
564 .min()
565 .unwrap_or(total)
566 .saturating_sub(1);
567 if self.bottom_follow_pending {
568 if height > 0 {
569 self.scroll = end.saturating_sub(height - 1);
570 }
571 } else if self.follow_selection {
572 if start < self.scroll {
573 self.scroll = start;
574 } else if height > 0 && start >= self.scroll + height {
575 self.scroll = start.saturating_sub(height - 1);
576 }
577 }
578 }
579 self.bottom_follow_pending = false;
580 self.scroll = self.scroll.min(total.saturating_sub(height));
581
582 let selected_id = self.thread.selected().map(|t| t.id);
583 self.row_map.clear();
584 let mut lines: Vec<Line<'static>> = Vec::new();
585 for (slot, line) in rows
586 .into_iter()
587 .skip(self.scroll as usize)
588 .take(height as usize)
589 {
590 let row_turn_id = match &slot {
591 RowSlot::Turn(id) => *id,
592 RowSlot::Answer { turn_id, .. } => *turn_id,
593 };
594 let line = if focused && Some(row_turn_id) == selected_id {
595 line.style(Style::default().bg(theme.selection_bg.to_ratatui()))
596 } else {
597 line
598 };
599 self.row_map.push(slot);
600 lines.push(line);
601 }
602 f.render_widget(Paragraph::new(lines), rect);
603 }
604
605 fn render_composer(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
606 self.composer_rect = rect;
607
608 let enabled = self.client.is_some();
609 let title = if enabled {
610 "Ask a question"
611 } else {
612 "server unavailable"
613 };
614 let block = panel_block(title, theme, focused);
615 let inner = block.inner(rect);
616 f.render_widget(block, rect);
617
618 let style = if enabled {
619 Style::default().fg(theme.fg.to_ratatui())
620 } else {
621 Style::default()
622 .fg(theme.gray.to_ratatui())
623 .add_modifier(Modifier::DIM)
624 };
625 self.composer.render(f, inner, style, 0, focused && enabled);
626 }
627}
628
629impl Default for ThreadPanel {
630 fn default() -> Self {
631 Self::new()
632 }
633}
634
635impl Component for ThreadPanel {
636 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
637 let chunks = Layout::default()
638 .direction(Direction::Vertical)
639 .constraints([Constraint::Min(0), Constraint::Length(COMPOSER_HEIGHT)])
640 .split(rect);
641 self.render_turns(
642 f,
643 chunks[0],
644 theme,
645 focused && self.focus == ThreadFocus::Turns,
646 );
647 self.render_composer(
648 f,
649 chunks[1],
650 theme,
651 focused && self.focus == ThreadFocus::Composer,
652 );
653 }
654
655 fn hint_shortcuts(&self) -> Vec<(String, String)> {
656 match self.focus {
657 ThreadFocus::Composer => vec![
658 ("Enter".into(), "Ask".into()),
659 ("Esc".into(), "Turns".into()),
660 ],
661 ThreadFocus::Turns => vec![
662 ("j/k".into(), "Select".into()),
663 ("PgUp/PgDn".into(), "Scroll".into()),
664 ("i//".into(), "Compose".into()),
665 ("y".into(), "Copy".into()),
666 ("e".into(), "Save as note".into()),
667 ("r".into(), "Regenerate".into()),
668 ],
669 }
670 }
671
672 }
676
677#[allow(clippy::too_many_arguments)]
681fn render_turn(
682 turn: &Turn,
683 width: u16,
684 is_first: bool,
685 prompt: &str,
686 question_style: Style,
687 sep_style: Style,
688 dim: Style,
689 err: Style,
690 md: &crate::components::markdown_lines::MdStyles,
691 out: &mut Vec<(RowSlot, Line<'static>)>,
692) {
693 if !is_first {
696 out.push((RowSlot::Turn(turn.id), separator_line(width, sep_style)));
697 }
698 let question = format!("{prompt} {}", turn.question);
699 for qline in wrap_text(&question, width) {
700 out.push((
701 RowSlot::Turn(turn.id),
702 Line::from(Span::styled(question[qline].to_string(), question_style)),
703 ));
704 }
705 match &turn.status {
706 TurnStatus::Thinking | TurnStatus::Streaming => {
707 out.push((
708 RowSlot::Turn(turn.id),
709 Line::from(Span::styled("… thinking", dim)),
710 ));
711 }
712 TurnStatus::Error(msg) => {
713 let text = format!("✗ {msg}");
714 for eline in wrap_text(&text, width) {
715 out.push((
716 RowSlot::Turn(turn.id),
717 Line::from(Span::styled(text[eline].to_string(), err)),
718 ));
719 }
720 out.push((
721 RowSlot::Turn(turn.id),
722 Line::from(Span::styled(" [r] retry", dim)),
723 ));
724 }
725 TurnStatus::Done => render_answer(turn, width, md, out),
726 }
727 out.push((RowSlot::Turn(turn.id), Line::default()));
728}
729
730fn separator_line(width: u16, style: Style) -> Line<'static> {
732 Line::from(Span::styled("─".repeat(width as usize), style))
733}
734
735fn render_answer(
745 turn: &Turn,
746 width: u16,
747 md: &crate::components::markdown_lines::MdStyles,
748 out: &mut Vec<(RowSlot, Line<'static>)>,
749) {
750 use crate::components::markdown_lines;
751 let logicals: Vec<&str> = turn
757 .answer
758 .split_inclusive('\n')
759 .map(|l| l.strip_suffix('\n').unwrap_or(l))
760 .collect();
761 let kinds = markdown_lines::classify_block_kinds(&logicals);
762 let mut offset = 0usize;
763 for (logical, &kind) in turn.answer.split_inclusive('\n').zip(kinds.iter()) {
764 let stripped = logical.strip_suffix('\n').unwrap_or(logical);
765 let line_start = offset;
766 for rel in wrap_text(stripped, width) {
767 let abs = (line_start + rel.start)..(line_start + rel.end);
768 let (line, col_map) =
769 markdown_lines::style_slice_mapped(&turn.answer[abs.clone()], kind, md);
770 out.push((
771 RowSlot::Answer {
772 turn_id: turn.id,
773 range: abs,
774 col_map,
775 },
776 line,
777 ));
778 }
779 offset += logical.len();
780 }
781}
782
783fn citation_at_column(slice: &str, map: &[usize], col: u16) -> Option<usize> {
790 let mut w: u16 = 0;
791 for &raw in map {
792 let ch = slice[raw..].chars().next()?;
793 let cw = (ch.width().unwrap_or(0) as u16).max(1);
794 if col < w + cw {
795 return citations::scan(slice)
796 .into_iter()
797 .find(|c| c.range.contains(&raw))
798 .map(|c| c.index);
799 }
800 w += cw;
801 }
802 None
803}
804
805fn wrap_text(text: &str, width: u16) -> Vec<Range<usize>> {
813 let width = width.max(1) as usize;
814 let mut lines = Vec::new();
815 let mut para_start = 0;
816 for (i, ch) in text.char_indices() {
817 if ch == '\n' {
818 wrap_paragraph(text, para_start..i, width, &mut lines);
819 para_start = i + 1;
820 }
821 }
822 wrap_paragraph(text, para_start..text.len(), width, &mut lines);
823 lines
824}
825
826fn wrap_paragraph(text: &str, para: Range<usize>, width: usize, out: &mut Vec<Range<usize>>) {
828 let words = word_ranges(text, para.clone());
829 let Some(first) = words.first() else {
830 out.push(para.start..para.start);
831 return;
832 };
833 let mut line_start = first.start;
834 let mut line_end = first.end;
835 let mut line_w = text[first.clone()].width();
836 for w in &words[1..] {
837 let word_w = text[w.clone()].width();
838 if line_w + 1 + word_w > width {
839 out.push(line_start..line_end);
840 line_start = w.start;
841 line_end = w.end;
842 line_w = word_w;
843 } else {
844 line_end = w.end;
845 line_w += 1 + word_w;
846 }
847 }
848 out.push(line_start..line_end);
849}
850
851fn word_ranges(text: &str, range: Range<usize>) -> Vec<Range<usize>> {
855 let bytes = text.as_bytes();
856 let mut words = Vec::new();
857 let mut i = range.start;
858 while i < range.end {
859 while i < range.end && bytes[i] == b' ' {
860 i += 1;
861 }
862 if i >= range.end {
863 break;
864 }
865 let start = i;
866 while i < range.end && bytes[i] != b' ' {
867 i += 1;
868 }
869 words.push(start..i);
870 }
871 words
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use ratatui::crossterm::event::KeyModifiers;
878
879 fn test_client() -> Arc<RagClient> {
882 Arc::new(RagClient::new(
883 "http://localhost:0".to_string(),
884 None,
885 "vault".to_string(),
886 ))
887 }
888
889 fn test_panel() -> ThreadPanel {
891 let mut p = ThreadPanel::new();
892 p.composer.set_value("q");
893 p
894 }
895
896 fn test_panel_online() -> ThreadPanel {
897 let mut p = ThreadPanel::new();
898 p.set_client(Some(test_client()));
899 p
900 }
901
902 fn p_handle_enter(p: &mut ThreadPanel) -> EventState {
903 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
904 let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
905 p.handle_input(&InputEvent::Key(key), &tx)
906 }
907
908 #[test]
909 fn new_thread_panel_starts_empty_without_client_and_composer_focus() {
910 let panel = ThreadPanel::new();
911 assert!(panel.thread().is_empty());
912 assert!(!panel.has_client());
913 assert_eq!(panel.focus, ThreadFocus::Composer);
914 }
915
916 #[test]
917 fn set_client_toggles_the_composer_enable_signal() {
918 let mut panel = ThreadPanel::new();
919 assert!(!panel.has_client());
920 panel.set_client(Some(test_client()));
921 assert!(panel.has_client());
922 panel.set_client(None);
923 assert!(!panel.has_client());
924 }
925
926 #[test]
927 fn thread_mut_allows_mutating_the_conversation() {
928 let mut panel = ThreadPanel::new();
929 panel.thread_mut().ask("q?".to_string());
930 assert_eq!(panel.thread().turns().len(), 1);
931 }
932
933 #[tokio::test]
934 async fn enter_submits_only_with_a_client() {
935 let mut p = test_panel(); let _ = p_handle_enter(&mut p);
937 assert!(p.thread().is_empty(), "no client → no turn");
938
939 p.set_client(Some(test_client()));
943 let _ = p_handle_enter(&mut p);
944 assert_eq!(p.thread().turns().len(), 1);
945 assert!(matches!(
946 p.thread().selected().unwrap().status,
947 TurnStatus::Thinking
948 ));
949 }
950
951 #[test]
952 fn answer_ready_completes_matching_turn_only() {
953 let mut p = test_panel_online();
954 let id = p.thread_mut().ask("q".into());
955 p.handle_data(AskData::AnswerReady {
956 turn_id: 999,
957 result: Ok(("x".into(), vec![])),
958 });
959 assert!(matches!(
960 p.thread().selected().unwrap().status,
961 TurnStatus::Thinking
962 ));
963 p.handle_data(AskData::AnswerReady {
964 turn_id: id,
965 result: Ok(("a".into(), vec![])),
966 });
967 assert!(matches!(
968 p.thread().selected().unwrap().status,
969 TurnStatus::Done
970 ));
971 }
972
973 #[test]
974 fn begin_turn_is_none_without_a_client() {
975 let mut p = ThreadPanel::new(); p.composer.set_value("hello");
977 assert!(p.begin_turn().is_none());
978 assert!(
979 p.thread().is_empty(),
980 "no client → no orphaned Thinking turn"
981 );
982 }
983
984 #[test]
985 fn begin_turn_is_none_when_composer_empty() {
986 let mut p = ThreadPanel::new();
987 p.set_client(Some(test_client()));
988 p.composer.set_value(" ");
989 assert!(p.begin_turn().is_none());
990 assert!(p.thread().is_empty());
991 }
992
993 #[test]
994 fn begin_turn_pushes_a_thinking_turn_and_selects_it() {
995 let mut p = ThreadPanel::new();
996 p.set_client(Some(test_client()));
997 p.composer.set_value("hello");
998 let (question, history, turn_id) = p.begin_turn().expect("client + non-empty");
999 assert_eq!(question, "hello");
1000 assert!(history.is_empty());
1001 assert_eq!(p.thread().turns().len(), 1);
1002 assert_eq!(p.thread().selected().unwrap().id, turn_id);
1003 }
1004
1005 #[test]
1006 fn esc_in_composer_moves_focus_to_turns() {
1007 let mut p = ThreadPanel::new();
1008 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1009 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1010 let state = p.handle_input(&InputEvent::Key(key), &tx);
1011 assert_eq!(state, EventState::Consumed);
1012 assert_eq!(p.focus, ThreadFocus::Turns);
1013 }
1014
1015 #[test]
1016 fn jk_in_turns_moves_selection() {
1017 let mut p = ThreadPanel::new();
1018 let first = p.thread_mut().ask("a".into());
1019 p.thread_mut().complete(first, "a!".into(), vec![]);
1020 let second = p.thread_mut().ask("b".into());
1021 p.thread_mut().complete(second, "b!".into(), vec![]);
1022 p.focus = ThreadFocus::Turns;
1023
1024 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1025 let key = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
1026 p.handle_input(&InputEvent::Key(key), &tx);
1027 assert_eq!(p.thread().selected().unwrap().id, first);
1028 }
1029
1030 #[test]
1031 fn regenerate_without_a_client_does_nothing() {
1032 let mut p = ThreadPanel::new();
1035 let id = p.thread_mut().ask("q".into());
1036 p.thread_mut().complete(id, "a".into(), vec![]);
1037 p.focus = ThreadFocus::Turns;
1038
1039 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1040 let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
1041 p.handle_input(&InputEvent::Key(key), &tx);
1042 assert!(
1043 matches!(p.thread().selected().unwrap().status, TurnStatus::Done),
1044 "no client → the completed turn stays Done"
1045 );
1046 assert_eq!(p.thread().selected().unwrap().id, id);
1047 }
1048
1049 #[test]
1050 fn i_and_slash_move_focus_to_composer() {
1051 for ch in ['i', '/'] {
1052 let mut p = ThreadPanel::new();
1053 p.focus = ThreadFocus::Turns;
1054 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1055 let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
1056 p.handle_input(&InputEvent::Key(key), &tx);
1057 assert_eq!(p.focus, ThreadFocus::Composer);
1058 }
1059 }
1060
1061 #[test]
1062 fn wrap_text_breaks_on_spaces_within_width() {
1063 let lines = wrap_text("one two three", 7);
1064 let text = "one two three";
1065 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1066 assert_eq!(rendered, vec!["one two", "three"]);
1067 }
1068
1069 #[test]
1070 fn wrap_text_keeps_an_overlong_word_on_its_own_line() {
1071 let lines = wrap_text("a superlongword b", 5);
1072 let text = "a superlongword b";
1073 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1074 assert_eq!(rendered, vec!["a", "superlongword", "b"]);
1075 }
1076
1077 #[test]
1078 fn wrap_text_forces_a_break_on_newline() {
1079 let lines = wrap_text("a\nb", 10);
1080 let text = "a\nb";
1081 let rendered: Vec<&str> = lines.iter().map(|r| &text[r.clone()]).collect();
1082 assert_eq!(rendered, vec!["a", "b"]);
1083 }
1084
1085 fn identity_map(slice: &str) -> Vec<usize> {
1088 slice.char_indices().map(|(i, _)| i).collect()
1089 }
1090
1091 #[test]
1092 fn citation_at_column_finds_the_marker_under_the_click() {
1093 let text = "Fact [1] more";
1094 let map = identity_map(text);
1095 let idx = citation_at_column(text, &map, 5);
1096 assert_eq!(idx, Some(1));
1097 let idx = citation_at_column(text, &map, 0);
1098 assert_eq!(idx, None);
1099 }
1100
1101 #[test]
1105 fn citation_hit_test_resolves_through_hidden_emphasis() {
1106 use crate::components::markdown_lines::{self, LineKind, MdStyles};
1107 let md = MdStyles::from_theme(&Theme::default());
1108 let raw = "**bold** then [1] tail";
1109 let (line, col_map) = markdown_lines::style_slice_mapped(raw, LineKind::Normal, &md);
1110 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1111 assert_eq!(text, "bold then [1] tail");
1112 let col = text.find("[1]").unwrap() as u16 + 1; assert_eq!(citation_at_column(raw, &col_map, col), Some(1));
1115 }
1116
1117 #[test]
1118 fn click_turns_selects_turn_and_resolves_citation_target() {
1119 let mut p = ThreadPanel::new();
1120 let first = p.thread_mut().ask("a".into());
1121 p.thread_mut().complete(
1122 first,
1123 "See [1] for it".into(),
1124 vec![AskSource {
1125 path: kimun_core::nfs::VaultPath::new("a.md"),
1126 heading: "h".into(),
1127 date: None,
1128 score: 1.0,
1129 text: String::new(),
1130 ordinal: 1,
1131 }],
1132 );
1133 let second = p.thread_mut().ask("b".into());
1134 p.thread_mut().complete(second, "b!".into(), vec![]);
1135 p.turns_rect = Rect::new(0, 0, 40, 20);
1137 let answer_slice = "See [1] for it";
1138 p.row_map = vec![
1139 RowSlot::Turn(first),
1140 RowSlot::Answer {
1141 turn_id: first,
1142 range: 0..answer_slice.len(),
1143 col_map: identity_map(answer_slice),
1144 },
1145 RowSlot::Turn(first),
1146 RowSlot::Turn(second),
1147 ];
1148 let mouse = MouseEvent {
1149 kind: MouseEventKind::Down(MouseButton::Left),
1150 column: 4, row: 1,
1152 modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
1153 };
1154 p.click_turns(&mouse);
1155 assert_eq!(p.thread().selected().unwrap().id, first);
1156 assert_eq!(p.take_citation_target(), Some(1));
1158 }
1159
1160 #[test]
1164 fn separators_part_turns_and_question_line_stands_out() {
1165 use crate::components::markdown_lines::MdStyles;
1166 let theme = Theme::default();
1167 let md = MdStyles::from_theme(&theme);
1168 let qstyle = Style::default()
1169 .fg(theme.accent.to_ratatui())
1170 .add_modifier(Modifier::BOLD);
1171 let sep = Style::default()
1172 .fg(theme.gray.to_ratatui())
1173 .add_modifier(Modifier::DIM);
1174 let dim = Style::default();
1175 let err = Style::default();
1176
1177 let mut thread = Thread::default();
1178 let a = thread.ask("first".into());
1179 thread.complete(a, "ans a".into(), vec![]);
1180 let b = thread.ask("second".into());
1181 thread.complete(b, "ans b".into(), vec![]);
1182
1183 let mut rows: Vec<(RowSlot, Line<'static>)> = Vec::new();
1184 for (i, turn) in thread.turns().iter().enumerate() {
1185 render_turn(turn, 40, i == 0, ">", qstyle, sep, dim, err, &md, &mut rows);
1186 }
1187
1188 let is_sep = |l: &Line<'static>| l.spans.iter().any(|s| s.content.contains('─'));
1189 assert_eq!(rows.iter().filter(|(_, l)| is_sep(l)).count(), 1);
1191 assert!(!is_sep(&rows[0].1), "no rule before the first turn");
1192 let sep_idx = rows.iter().position(|(_, l)| is_sep(l)).unwrap();
1193 assert!(matches!(rows[sep_idx].0, RowSlot::Turn(id) if id == b));
1194 assert_ne!(sep_idx, rows.len() - 1, "no rule after the last turn");
1195
1196 let (_, qline) = rows
1198 .iter()
1199 .find(|(_, l)| l.spans.iter().any(|s| s.content.contains("first")))
1200 .unwrap();
1201 assert!(
1202 qline.spans[0].content.starts_with('>'),
1203 "carries the prompt"
1204 );
1205 assert_eq!(qline.spans[0].style, qstyle, "accent + bold");
1206 }
1207
1208 mod rendering {
1209 use super::*;
1210 use crate::settings::themes::Theme;
1211 use ratatui::Terminal;
1212 use ratatui::backend::TestBackend;
1213
1214 fn draw(p: &mut ThreadPanel, theme: &Theme, width: u16, height: u16, focused: bool) {
1215 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1216 terminal
1217 .draw(|f| {
1218 let area = f.area();
1219 p.render(f, area, theme, focused);
1220 })
1221 .unwrap();
1222 }
1223
1224 #[test]
1227 fn clicking_a_separator_row_selects_its_turn() {
1228 let theme = Theme::default();
1229 let mut p = ThreadPanel::new();
1230 let a = p.thread_mut().ask("first".into());
1231 p.thread_mut().complete(a, "aaa".into(), vec![]);
1232 let b = p.thread_mut().ask("second".into());
1233 p.thread_mut().complete(b, "bbb".into(), vec![]);
1234 p.focus = ThreadFocus::Turns;
1235 p.thread_mut().select_index(0);
1237 draw(&mut p, &theme, 40, 12, true);
1238
1239 let sep_row = p
1241 .row_map
1242 .iter()
1243 .position(|s| matches!(s, RowSlot::Turn(id) if *id == b))
1244 .expect("turn b has rows on screen");
1245 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1246 let mouse = MouseEvent {
1247 kind: MouseEventKind::Down(MouseButton::Left),
1248 column: 0,
1249 row: p.turns_rect.y + sep_row as u16,
1250 modifiers: KeyModifiers::NONE,
1251 };
1252 p.handle_input(&InputEvent::Mouse(mouse), &tx);
1253 assert_eq!(p.thread().selected().unwrap().id, b);
1254 }
1255
1256 #[test]
1257 fn render_does_not_panic_across_states_and_sizes() {
1258 let theme = Theme::default();
1259 let mut p = ThreadPanel::new();
1260 p.set_client(Some(test_client())); draw(&mut p, &theme, 40, 10, true); let id = p
1264 .thread_mut()
1265 .ask("A fairly long question that should wrap across more than one line".into());
1266 draw(&mut p, &theme, 40, 10, true); p.thread_mut().complete(
1269 id,
1270 "An answer citing [1] a source and [2] another, spanning multiple \
1271 wrapped lines to exercise citation styling."
1272 .into(),
1273 vec![],
1274 );
1275 draw(&mut p, &theme, 40, 10, true); p.focus = ThreadFocus::Turns;
1277 draw(&mut p, &theme, 40, 10, true);
1278
1279 let id2 = p.thread_mut().ask("another".into());
1280 p.thread_mut().fail(id2, "boom".into());
1281 draw(&mut p, &theme, 40, 10, true); p.set_client(None);
1284 draw(&mut p, &theme, 40, 10, false); draw(&mut p, &theme, 3, 3, true); draw(&mut p, &theme, 0, 0, true); }
1289
1290 fn turns_key(p: &mut ThreadPanel, code: KeyCode) {
1291 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1292 p.handle_input(
1293 &InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)),
1294 &tx,
1295 );
1296 }
1297
1298 #[test]
1304 fn markdown_answer_keeps_prose_citations_clickable() {
1305 let theme = Theme::default();
1306 let mut p = ThreadPanel::new();
1307 let id = p.thread_mut().ask("q".into());
1308 let answer = "# Title\nSee [1] here.\n```\nlet x = arr[9];\n```".to_string();
1309 p.thread_mut().complete(
1310 id,
1311 answer.clone(),
1312 vec![AskSource {
1313 path: kimun_core::nfs::VaultPath::new("a.md"),
1314 heading: "h".into(),
1315 date: None,
1316 score: 1.0,
1317 text: String::new(),
1318 ordinal: 1,
1319 }],
1320 );
1321 p.focus = ThreadFocus::Turns;
1322 draw(&mut p, &theme, 60, 12, true);
1323
1324 let hit = p.row_map.iter().find_map(|slot| match slot {
1327 RowSlot::Answer { range, col_map, .. } if answer[range.clone()].contains("[1]") => {
1328 let slice = &answer[range.clone()];
1329 let col = slice.find("[1]").unwrap() as u16 + 1;
1330 Some(citation_at_column(slice, col_map, col))
1331 }
1332 _ => None,
1333 });
1334 assert_eq!(
1335 hit,
1336 Some(Some(1)),
1337 "the prose citation resolves through the rendered slice"
1338 );
1339 }
1340
1341 #[test]
1344 fn completion_bottom_follows_to_show_the_answer_end() {
1345 let theme = Theme::default();
1346 let mut p = ThreadPanel::new();
1347 p.set_client(Some(test_client()));
1348 let id = p.thread_mut().ask("q".into());
1349 p.focus = ThreadFocus::Turns;
1350 let answer = (0..10)
1352 .map(|i| format!("line{i}"))
1353 .collect::<Vec<_>>()
1354 .join("\n");
1355 p.handle_data(AskData::AnswerReady {
1356 turn_id: id,
1357 result: Ok((answer, vec![])),
1358 });
1359 draw(&mut p, &theme, 60, 8, true);
1362 assert_eq!(p.scroll, 7, "bottom-follow shows the answer's end");
1363 }
1364
1365 #[test]
1369 fn completion_of_an_unselected_turn_leaves_scroll_untouched() {
1370 let mut p = ThreadPanel::new();
1371 p.set_client(Some(test_client()));
1372 let old = p.thread_mut().ask("old".into());
1373 p.thread_mut().complete(old, "old answer".into(), vec![]);
1374 let new = p.thread_mut().ask("new".into());
1375 p.thread_mut().complete(new, "new answer".into(), vec![]);
1376 p.thread_mut().select_last();
1379 p.scroll = 4;
1380 p.follow_selection = false;
1381 p.bottom_follow_pending = false;
1382
1383 p.handle_data(AskData::AnswerReady {
1385 turn_id: old,
1386 result: Ok(("regenerated".into(), vec![])),
1387 });
1388 assert_eq!(p.scroll, 4, "unselected completion must not move scroll");
1389 assert!(!p.follow_selection, "follow flags untouched");
1390 assert!(!p.bottom_follow_pending, "bottom-follow not armed");
1391
1392 let newer = p.thread_mut().ask("newer".into());
1394 p.handle_data(AskData::AnswerReady {
1395 turn_id: newer,
1396 result: Ok(("visible".into(), vec![])),
1397 });
1398 assert!(
1399 p.bottom_follow_pending,
1400 "selected completion follows bottom"
1401 );
1402 }
1403
1404 #[test]
1407 fn selection_scrolls_into_view_and_content_scroll_clamps() {
1408 let theme = Theme::default();
1409 let mut p = ThreadPanel::new();
1410 for i in 0..8 {
1411 let id = p.thread_mut().ask(format!("q{i}"));
1412 p.thread_mut().complete(id, format!("a{i}"), vec![]);
1413 }
1414 p.focus = ThreadFocus::Turns;
1415 draw(&mut p, &theme, 60, 9, true); for _ in 0..8 {
1423 turns_key(&mut p, KeyCode::Char('k'));
1424 }
1425 draw(&mut p, &theme, 60, 9, true);
1426 assert_eq!(
1427 p.scroll, 0,
1428 "selecting the first turn scrolled it into view"
1429 );
1430
1431 turns_key(&mut p, KeyCode::End);
1433 draw(&mut p, &theme, 60, 9, true);
1434 assert_eq!(p.scroll, 25, "content scroll clamps to the last page");
1435 }
1436 }
1437}